From 9f405cafe3d8c4c879faba71c239d7e9e9dbe9e9 Mon Sep 17 00:00:00 2001 From: mingkun Date: Sun, 23 Feb 2025 21:22:41 +0800 Subject: [PATCH] add interative notebook --- notebook/0_tokenizer.ipynb | 453 ++++++++++++++++++ notebook/1_model.ipynb | 728 +++++++++++++++++++++++++++++ notebook/2_dataset.ipynb | 574 +++++++++++++++++++++++ notebook/3_pretrain.ipynb | 372 +++++++++++++++ notebook/4_sft.ipynb | 382 +++++++++++++++ notebook/5_dpo.ipynb | 456 ++++++++++++++++++ notebook/demo/LMConfig.py | 61 +++ notebook/demo/dataset.py | 200 ++++++++ notebook/demo/dpo_data.jsonl | 2 + notebook/demo/gqa.png | Bin 0 -> 65820 bytes notebook/demo/model.py | 376 +++++++++++++++ notebook/demo/pretrain_data.jsonl | 2 + notebook/demo/sft_data.jsonl | 2 + notebook/demo/tokenizer_data.jsonl | 3 + 14 files changed, 3611 insertions(+) create mode 100644 notebook/0_tokenizer.ipynb create mode 100644 notebook/1_model.ipynb create mode 100644 notebook/2_dataset.ipynb create mode 100644 notebook/3_pretrain.ipynb create mode 100644 notebook/4_sft.ipynb create mode 100644 notebook/5_dpo.ipynb create mode 100644 notebook/demo/LMConfig.py create mode 100644 notebook/demo/dataset.py create mode 100644 notebook/demo/dpo_data.jsonl create mode 100644 notebook/demo/gqa.png create mode 100644 notebook/demo/model.py create mode 100644 notebook/demo/pretrain_data.jsonl create mode 100644 notebook/demo/sft_data.jsonl create mode 100644 notebook/demo/tokenizer_data.jsonl diff --git a/notebook/0_tokenizer.ipynb b/notebook/0_tokenizer.ipynb new file mode 100644 index 0000000..20e4b30 --- /dev/null +++ b/notebook/0_tokenizer.ipynb @@ -0,0 +1,453 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "source": [ + "# 0-Tokenizer\n", + "\n", + "对一段文本进行分词就是将其分割成单词或子词,然后通过查找表将这些单词或子词转换为对应的编号。\n", + "\n", + "分词器的就是把一段文本分割成单词或子词的工具。 " + ] + }, + { + "cell_type": "markdown", + "metadata": { + "vscode": { + "languageId": "plaintext" + } + }, + "source": [ + "## 子词分词算法\n", + "\n", + "常见的子词分词算法有三种:\n", + "\n", + "1. 字节对编码(Byte Pair Encoding,BPE)\n", + "2. WordPiece\n", + "3. Unigram\n", + "\n", + "### BPE\n", + "\n", + "BPE 是一种简单的数据压缩技术,它会迭代地替换序列中最频繁出现的字节对。BPE 依赖一个预分词器,该预分词器会将训练数据分割成单词(在本项目中,我们使用按空格分词的方法作为预分词方法)。\n", + "\n", + "在预分词之后,会创建一组唯一的单词,并确定它们在数据中的出现频率。接下来,BPE 会创建一个基础词表,该词表包含预分词器最初生成的数据中所有唯一单词的符号。然后,会将这对符号从词表中移除,新形成的符号将加入词表。在迭代过程中,BPE 算法会合并频繁出现的符号对。\n", + "\n", + "给定词表的大小,BPE(字节对编码)算法最终会合并出现频率最高的符号对,直到收敛到该大小。\n", + "\n", + "### WordPiece\n", + "\n", + "WordPiece 算法与 BPE 非常相似。WordPiece 首先将词表初始化为包含训练数据中出现的每个字符,然后逐步学习给定数量的合并规则。与 BPE 不同的是,WordPiece 并不选择最频繁出现的符号对,而是选择那个加入词表后能使训练数据出现的可能性最大化的符号对。\n", + "\n", + "### Unigram\n", + "\n", + "Unigram 算法将其基础词表初始化为大量的符号,然后逐步削减每个符号,以获得一个更小的词表。它会在训练数据上定义一个对数似然损失,以此来确定是否从词表中移除某个符号。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## 训练一个最简单的分词器\n", + "\n", + "在本节中,我们将学习基于 transformers 库来训练你自己的分词器。" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 初始化\n", + "\n", + "首先,我们应该初始化我们的分词器,并确定选择哪种方法。我们将使用字节对编码(BPE)算法。" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from tokenizers import (\n", + " decoders,\n", + " models,\n", + " normalizers,\n", + " pre_tokenizers,\n", + " processors,\n", + " trainers,\n", + " Tokenizer,\n", + ")\n", + "\n", + "tokenizer = Tokenizer(models.BPE())\n", + "tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 定义特殊标记\n", + "\n", + "数据集中存在一些我们不希望被分词的特殊标记,我们会将这些标记定义为特殊标记,并将它们传递给分词器训练器,以防止出现错误的分词情况。 " + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [], + "source": [ + "special_tokens = [\"\", \"\", \"\"]\n", + "\n", + "trainer = trainers.BpeTrainer(\n", + " vocab_size=256,\n", + " special_tokens=special_tokens,\n", + " show_progress=True,\n", + " initial_alphabet=pre_tokenizers.ByteLevel.alphabet()\n", + ")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 从文件中读取数据\n", + "我们使用 JSON Lines(jsonl)格式来存储训练数据,让我们编写函数来处理这些数据。" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "\n", + "def read_texts_from_jsonl(file_path):\n", + " with open(file_path, 'r', encoding='utf-8') as f:\n", + " for line in f:\n", + " data = json.loads(line)\n", + " yield data['text']\n", + "\n", + "data_path = './demo/tokenizer_data.jsonl'" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'近年来,人工智能技术迅速发展,深刻改变了各行各业的面貌。机器学习、自然语言处理、计算机视觉等领域的突破性进展,使得智能产品和服务越来越普及。从智能家居到自动驾驶,再到智能医疗,AI的应用场景正在快速拓展。随着技术的不断进步,未来的人工智能将更加智能、更加贴近人类生活。'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "data_iter = read_texts_from_jsonl(data_path)\n", + "next(data_iter)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 开始训练!\n", + "\n", + "我们使用分词器的内置函数来训练分词器,轻松愉快。" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "tokenizer.train_from_iterator(data_iter, trainer=trainer)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 设置解码器" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [], + "source": [ + "tokenizer.decoder = decoders.ByteLevel()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "接下来,检查一下特殊标记是否得到了妥善处理。" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [], + "source": [ + "assert tokenizer.token_to_id('') == 0\n", + "assert tokenizer.token_to_id('') == 1\n", + "assert tokenizer.token_to_id('') == 2" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 将训练好的分词器保存到磁盘" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "['./demo/tokenizer_demo\\\\vocab.json', './demo/tokenizer_demo\\\\merges.txt']" + ] + }, + "execution_count": 8, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "import os\n", + "\n", + "tokenizer_dir = \"./demo/tokenizer_demo\"\n", + "os.makedirs(tokenizer_dir, exist_ok=True)\n", + "tokenizer.save(os.path.join(tokenizer_dir, \"tokenizer.json\")) # At this point, you will see a file named tokenizer.json under tokenizer_dir\n", + "tokenizer.model.save(tokenizer_dir) # generate vocab.json & merges.txt" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "### 手动创建一份配置文件" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Tokenizer training completed and saved.\n" + ] + } + ], + "source": [ + "config = {\n", + " \"add_bos_token\": False,\n", + " \"add_eos_token\": False,\n", + " \"add_prefix_space\": False,\n", + " \"added_tokens_decoder\": {\n", + " \"0\": {\n", + " \"content\": \"\",\n", + " \"lstrip\": False,\n", + " \"normalized\": False,\n", + " \"rstrip\": False,\n", + " \"single_word\": False,\n", + " \"special\": True\n", + " },\n", + " \"1\": {\n", + " \"content\": \"\",\n", + " \"lstrip\": False,\n", + " \"normalized\": False,\n", + " \"rstrip\": False,\n", + " \"single_word\": False,\n", + " \"special\": True\n", + " },\n", + " \"2\": {\n", + " \"content\": \"\",\n", + " \"lstrip\": False,\n", + " \"normalized\": False,\n", + " \"rstrip\": False,\n", + " \"single_word\": False,\n", + " \"special\": True\n", + " }\n", + " },\n", + " \"additional_special_tokens\": [],\n", + " \"bos_token\": \"\",\n", + " \"clean_up_tokenization_spaces\": False,\n", + " \"eos_token\": \"\",\n", + " \"legacy\": True,\n", + " \"model_max_length\": 32768,\n", + " \"pad_token\": \"\",\n", + " \"sp_model_kwargs\": {},\n", + " \"spaces_between_special_tokens\": False,\n", + " \"tokenizer_class\": \"PreTrainedTokenizerFast\",\n", + " \"unk_token\": \"\",\n", + " \"chat_template\": \"{{ '' + messages[0]['text'] + '' }}\"\n", + "}\n", + "\n", + "with open(os.path.join(tokenizer_dir, \"tokenizer_config.json\"), \"w\", encoding=\"utf-8\") as config_file:\n", + " json.dump(config, config_file, ensure_ascii=False, indent=4)\n", + "\n", + "print(\"Tokenizer training completed and saved.\")" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "现在我们已经训练了一个简单的分词器,并将其进行保存,接下来,我们试着加载它,并使用其帮助我们对文本进行编解码" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'失去的东西就要学着去接受,学着放下。'" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "from transformers import AutoTokenizer\n", + "\n", + "tokenizer = AutoTokenizer.from_pretrained(\"./demo/tokenizer_demo\")\n", + "msg = [{\"text\": \"失去的东西就要学着去接受,学着放下。\"}]\n", + "new_msg = tokenizer.apply_chat_template(\n", + " msg,\n", + " tokenize=False\n", + ")\n", + "new_msg" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "actural length of tokenizer: 259\n" + ] + } + ], + "source": [ + "actual_vocab_size = len(tokenizer)\n", + "print('actural length of tokenizer:', actual_vocab_size)" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'input_ids': [1, 164, 100, 112, 164, 239, 122, 166, 251, 229, 163, 119, 253, 167, 101, 126, 164, 111, 112, 167, 102, 226, 164, 258, 102, 166, 254, 225, 164, 239, 122, 165, 239, 101, 164, 240, 248, 174, 123, 237, 164, 258, 102, 166, 254, 225, 165, 245, 125, 163, 119, 236, 162, 225, 227, 2], 'token_type_ids': [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], 'attention_mask': [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1]}" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "model_inputs = tokenizer(new_msg)\n", + "model_inputs" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'失去的东西就要学着去接受,学着放下。'" + ] + }, + "execution_count": 13, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "response = tokenizer.decode(model_inputs['input_ids'], skip_special_tokens=False)\n", + "response" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'失去的东西就要学着去接受,学着放下。'" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "response = tokenizer.decode(model_inputs['input_ids'], skip_special_tokens=True)\n", + "response" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 4 +} diff --git a/notebook/1_model.ipynb b/notebook/1_model.ipynb new file mode 100644 index 0000000..b9ce3b9 --- /dev/null +++ b/notebook/1_model.ipynb @@ -0,0 +1,728 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f359c4f9-9082-4843-8874-4e7c6f3c7404", + "metadata": {}, + "source": [ + "# 1-Model" + ] + }, + { + "cell_type": "markdown", + "id": "2f5a93fb-2d09-4af4-9782-578357927842", + "metadata": {}, + "source": [ + "如今的主流大模型的架构大致分为稠密模型(Dense)和混合专家模型(MOE), 在这一板块,我们将围绕 MiniMind 系列模型的源代码展开学习。\n", + "\n", + "## MiniMind Dense Model\n", + "\n", + "MiniMind 系列模型在 Llama 3.1 的基础上设计,基于经典的 Transformer Deocder-Only 架构,其主要特点如下:\n", + "1. 采用 Pre-Norm 归一化方法,使用 RMSNorm 归一化函数。\n", + "2. 使用 SwiGLU 激活函数。\n", + "3. 旋转位置嵌入 (RoPE)\n", + "\n", + "作者提供了对其 MiniMind 模型结构的可视化:\n", + "\n", + "![](../images/LLM-structure.png)\n", + "\n", + "我们在上一节中完成了 Tokenizer 的学习,这一节我们关注 LLM 模型架构的具体实现。" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "6cd2aa37-3460-4a64-8285-f888d62b99d5", + "metadata": {}, + "outputs": [], + "source": [ + "import torch\n", + "import torch.nn as nn\n", + "import torch.nn.functional as F" + ] + }, + { + "cell_type": "markdown", + "id": "acbb3ad8-5143-45ff-af16-8dcc0cc2cf53", + "metadata": {}, + "source": [ + "### Root Mean Square Layer Normalization (RMSNorm)\n", + "\n", + "RMSNorm 是对 LayerNorm 的一个改进, 没有做 re-center 操作(移除了均值项),可以看作 LayerNorm 在均值为零时的特例,使用平方根均值归一化降低噪声影响。\n", + "\n", + "- Layer Norm\n", + "\n", + "$$y = \\frac{x-E(x)}{\\sqrt{Var(x) + \\epsilon}} * \\gamma + \\beta$$\n", + "\n", + "假设输入张量形状为 (batch_size, sequence_length, embedding_dim),层归一化会对 embedding_dim 维度进行归一化操作,其中, $\\epsilon$ 是一个超参数,用于防止非法运算(分母为零), $\\gamma$, $\\beta$ 均为可学习参数。\n", + "\n", + "- RMS Norm\n", + "\n", + "$$a_i=\\frac{a_i}{RMS(a) g_i}, where RMS(a) = \\sqrt{\\frac{1}{n}\\sum^n_{i=1}a^2_i}.$$\n", + "\n", + "假设输入张量形状为 (batch_size, sequence_length, embedding_dim),RMS归一化同样对 embedding_dim 维度进行归一化,其中,$g_i$ 为可学习参数,用于对均方根归一化结果进行加权。" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c52df1c5-1047-48d8-aa00-c0b576a87632", + "metadata": {}, + "outputs": [], + "source": [ + "class RMSNorm(torch.nn.Module):\n", + " def __init__(self, dim: int, eps: float):\n", + " super().__init__()\n", + " self.eps = eps\n", + " self.weight = nn.Parameter(torch.ones(dim))\n", + "\n", + " def forward(self, x):\n", + " return self.weight * (x.float() * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)).type_as(x)" + ] + }, + { + "cell_type": "markdown", + "id": "6207c186-9816-41fd-872d-d5116257b274", + "metadata": {}, + "source": [ + "### Rotary Position Embedding, RoPE\n", + "\n", + "旋转位置编码是一种能将相对位置信息集成到 self-attentio 中, 进而提升 transformer 架构性能的位置编码方式, 和绝对位置编码相比, RoPE 具有很好的外推性, 是目前的主流位置编码方式。\n", + "\n", + "外推性的解释, 通俗来说就是训练的时候限制了 512 的上下文长度,那么推理时如果面对超过该长度的文本,LLM 可能无法正确处理。\n", + "\n", + "- 绝对位置编码\n", + "\n", + "$$f_{t:t\\in\\{q,k,v\\}}(\\boldsymbol{x}_i,i):=\\boldsymbol{W}_{t:t\\in\\{q,k,v\\}}(\\boldsymbol{x}_i+\\boldsymbol{p}_i)$$\n", + "\n", + "其中编码向量 $p_i$ 的计算使用 Sinusoidal 函数:\n", + "\n", + "$$\\boldsymbol{p}_{i,2t}=\\sin\\left(k/1000^{2t/d}\\right), \\boldsymbol{p}_{i,2t+1}=\\cos\\left(k/1000^{2t/d}\\right)$$\n", + "\n", + "正如其名,绝对位置编码只考虑了输入序列中的绝对位置关系,对于 token 之间的相对信息则没有纳入考虑。\n", + "\n", + "- 旋转位置编码\n", + "\n", + "假定 query 和 key 的内积操作可以被函数 g 表示(有点像核函数),该函数 g 的输入是词嵌入向量 $x_m, x_n$ 和它们之间的相对位置 $m-n$:\n", + "\n", + "$$=g(x_m, x_n, m, n)$$\n", + "\n", + "旋转位置编码就是找到一个使上式成立的位置编码方式。\n", + "\n", + "$$f_{\\{q,k\\}}\\left(\\boldsymbol{x}_m,m\\right)=\\boldsymbol{R}_{\\Theta,m}^d\\boldsymbol{W}_{\\{q,k\\}}\\boldsymbol{x}_m$$\n", + "\n", + "$$\\boldsymbol{R}_{\\Theta,m}^d=\\underbrace{\\begin{pmatrix}\\cos m\\theta_0&-\\sin m\\theta_0&0&0&\\cdots&0&0\\\\\\sin m\\theta_0&\\cos m\\theta_0&0&0&\\cdots&0&0\\\\0&0&\\cos m\\theta_1&-\\sin m\\theta_1&\\cdots&0&0\\\\0&0&\\sin m\\theta_1&\\cos m\\theta_1&\\cdots&0&0\\\\\\vdots&\\vdots&\\vdots&\\vdots&\\ddots&\\vdots&\\vdots\\\\0&0&0&0&\\cdots&\\cos m\\theta_{d/2-1}&-\\sin m\\theta_{d/2-1}&-\\sin m\\theta_{d/2-1}\\end{pmatrix}}_{\\boldsymbol{W}_m}(13)$$\n", + "\n", + "$$\\Theta=\\left\\{\\theta_i=10000^{-2(i-1)/d},i\\in[1,2,\\ldots,d/2]\\right\\}$$\n", + "\n", + "由于 $\\boldsymbol{R}_{\\Theta,m}^d$ 的稀疏性,直接使用矩阵乘法会浪费算力,因此采用下述方式实现:\n", + "\n", + "$$\\boldsymbol{R}_{\\Theta,m}^{d}\\boldsymbol{x}=\\begin{pmatrix}x_{0}\\\\x_{1}\\\\x_{2}\\\\x_{3}\\\\\\vdots\\\\x_{d-2}\\\\x_{d-1}\\end{pmatrix}\\otimes\\begin{pmatrix}\\cos m\\theta_{0}\\\\\\cos m\\theta_{0}\\\\\\cos m\\theta_{1}\\\\\\cos m\\theta_{1}\\\\\\vdots\\\\\\cos m\\theta_{d/2-1}\\\\\\cos m\\theta_{d/2-1}\\end{pmatrix}+\\begin{pmatrix}-x_{1}\\\\x_{0}\\\\-x_{3}\\\\x_{2}\\\\\\vdots\\\\-x_{d-1}\\\\x_{d-2}\\end{pmatrix}\\otimes\\begin{pmatrix}\\sin m\\theta_{0}\\\\\\sin m\\theta_{0}\\\\\\sin m\\theta_{1}\\\\\\sin m\\theta_{1}\\\\\\vdots\\\\\\sin m\\theta_{d/2-1}\\\\\\sin m\\theta_{d/2-1}\\end{pmatrix}\n", + "$$" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "5aae4194-3738-44a7-9c33-bbf964067f8a", + "metadata": {}, + "outputs": [], + "source": [ + "# compute pe in complex form\n", + "def precompute_pos_cis(dim: int, end: int=int(32 * 1024), theta: float=1e6):\n", + " \"\"\"\n", + " compute pe frequency in complex form.\n", + " \"\"\"\n", + " freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))\n", + " t = torch.arange(end, device=freqs.device)\n", + " freqs = torch.outer(t, freqs).float()\n", + " pos_cis = torch.polar(torch.ones_like(freqs), freqs)\n", + " return pos_cis\n", + "\n", + "def apply_rotary_emb(xq, xk, pos_cis):\n", + " def unite_shape(pos_cis, x):\n", + " ndim = x.ndim\n", + " assert 0 <= 1 < ndim\n", + " assert pos_cis.shape == (x.shape[1], x.shape[-1])\n", + " shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)]\n", + " return pos_cis.view(*shape)\n", + "\n", + " xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))\n", + " print('original shape xq: {}'.format(xq.shape)) # 查看 xq 的 shape\n", + " print('ajusted shape xq: {}'.format(xq_.shape)) # 查看经过复数表示后 xq 的 shape\n", + " xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))\n", + " pos_cis = unite_shape(pos_cis, xq_)\n", + " print('ajusted shape pos_cis: {}'.format(pos_cis.shape)) # 查看位置编码旋转角的形状\n", + " xq_out = torch.view_as_real(xq_ * pos_cis).flatten(3)\n", + " xk_out = torch.view_as_real(xk_ * pos_cis).flatten(3)\n", + " return xq_out.type_as(xq), xk_out.type_as(xk)" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5f8376c4-5b00-4c72-9f23-912478db581c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(torch.Size([16, 32]), tensor(1.+0.j))" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "xq, xk = torch.randn((2, 16, 4, 64)), torch.randn((2, 16, 4, 64)) # (batch_size, sequence_length, num_heads, head_dim)\n", + "pos_cis = precompute_pos_cis(64, 16) # 计算旋转位置编码的旋转角(复数表示)\n", + "pos_cis.shape, pos_cis[0, 0]" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "b40d0ec8-8752-4db9-a4fe-e746d39dc970", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "original shape xq: torch.Size([2, 16, 4, 64])\n", + "ajusted shape xq: torch.Size([2, 16, 4, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 16, 1, 32])\n" + ] + } + ], + "source": [ + "xq_rope, xk_rope = apply_rotary_emb(xq, xk, pos_cis)" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "c03dd09c-9ccf-4716-bbd3-58a1314c05d5", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "(torch.Size([2, 16, 4, 64]), torch.Size([2, 16, 4, 64]))" + ] + }, + "execution_count": 6, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "xq_rope.shape, xk_rope.shape" + ] + }, + { + "cell_type": "markdown", + "id": "fa47eeb0-874e-4df3-bf91-5f79109af8ca", + "metadata": {}, + "source": [ + "### Attention\n", + "\n", + "Attention 是 Transformer 架构中的重要机制,它可以捕获长序列间的依赖关系,并依据注意力得分进行重要性建模。\n", + "\n", + "在 MiniMind 模型中,Attention Block 涉及几个重要机制。\n", + "\n", + "1. GQA\n", + "2. KV Cache\n", + "4. Pre-RMSNorm & RoPE\n", + "5. SwiGLU\n", + "6. FFN\n", + "\n", + "- GQA\n", + "\n", + "Group Querey Attention (GQA) 是对多头自注意力机制的扩展,通过提供计算效率和模型表达能力的灵活权衡,实现了查询头的分组。\n", + "\n", + "具体来说,GQA 将查询头分为 G 个组,每个组共享一个公共的键和值。\n", + "\n", + "![img](./demo/gqa.png)\n", + "\n", + "- KV Cache\n", + "\n", + "KV Cache 能够有效压缩大模型推理时的显存占用,在推理时,前面生成的字符不需要与后面的字符产生 attention,从而使得前面已经计算的 K 和 V 可以缓存起来。\n", + "\n", + "- SwiGLU\n", + "\n", + "$$\\text{SwiGLU}(x,W,V,b,c)=\\text{Swish}_1(xW+b)\\otimes(xV+c)$$" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "e61394db-5afa-4421-ba77-30a9c50f0603", + "metadata": {}, + "outputs": [], + "source": [ + "from demo.LMConfig import LMConfig\n", + "from typing import Any, Optional, Tuple, List\n", + "import torch.nn as nn\n", + "import math\n", + "\n", + "def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:\n", + " bs, slen, n_kv_heads, head_dim = x.shape\n", + " if n_rep == 1:\n", + " return x\n", + " return (\n", + " x[:, :, :, None, :]\n", + " .expand(bs, slen, n_kv_heads, n_rep, head_dim)\n", + " .reshape(bs, slen, n_kv_heads * n_rep, head_dim)\n", + " )\n", + "\n", + "class Attention(nn.Module):\n", + " def __init__(self, args: LMConfig):\n", + " super().__init__()\n", + " self.n_kv_heads = args.n_heads if args.n_kv_heads is None else args.n_kv_heads\n", + " assert args.n_heads % self.n_kv_heads == 0\n", + " self.n_local_heads = args.n_heads\n", + " self.n_local_kv_heads = args.n_kv_heads\n", + " self.n_rep = self.n_local_heads // self.n_local_kv_heads\n", + " self.head_dim = args.dim // args.n_heads\n", + " self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)\n", + " self.wk = nn.Linear(args.dim, args.n_kv_heads * self.head_dim, bias=False)\n", + " self.wv = nn.Linear(args.dim, args.n_kv_heads * self.head_dim, bias=False)\n", + " self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False)\n", + " self.attn_dropout = nn.Dropout(args.dropout)\n", + " self.resid_dropout = nn.Dropout(args.dropout)\n", + " self.dropout = args.dropout\n", + " self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention') and args.flash_attn\n", + " mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float(\"-inf\"))\n", + " mask = torch.triu(mask, diagonal=1)\n", + " self.register_buffer(\"mask\", mask, persistent=False)\n", + "\n", + " def forward(self,\n", + " x: torch.Tensor,\n", + " pos_cis: torch.Tensor,\n", + " past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None,\n", + " use_cache=False):\n", + " bsz, seq_len, _ = x.shape\n", + " ############## Forward QKV & RoPE ##############\n", + " xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)\n", + " xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim)\n", + " xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)\n", + " xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim)\n", + " xq, xv = apply_rotary_emb(xq, xk, pos_cis)\n", + " ################### KV Cache ###################\n", + " if past_key_value is not None:\n", + " xk = torch.cat([past_key_value[0], xk], dim=1)\n", + " xv = torch.cat([past_key_value[1], xv], dim=1)\n", + " past_kv = (xk, xv) if use_cache else None\n", + " xq, xk, xv = (\n", + " xq.transpose(1, 2),\n", + " repeat_kv(xk, self.n_rep).transpose(1, 2),\n", + " repeat_kv(xv, self.n_rep).transpose(1, 2)\n", + " )\n", + " ############ Scaled Dot Production #############\n", + " if self.flash and seq_len != 1:\n", + " dropout_p = self.dropout if self.training else 0.0\n", + " output = F.scaled_dot_product_attention(\n", + " xq, xk, xv,\n", + " attn_mask=None,\n", + " dropout_p=dropout_p,\n", + " is_causal=True\n", + " )\n", + " else:\n", + " scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim)\n", + " scores += self.mask[:, :, :seq_len, :seq_len]\n", + " scores = F.softmax(scores.float(), dim=-1).type_as(xq)\n", + " scores = self.attn_dropout(scores)\n", + " output = scores @ xv\n", + " ################################################\n", + " output = output.transpose(1, 2).reshape(bsz, seq_len, -1)\n", + " output = self.resid_dropout(self.wo(output))\n", + " return output, past_kv" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "ca9f6675-7d70-49b4-ba02-f91109869fd8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "torch.Size([4, 16, 512])\n", + "torch.Size([16, 32])\n", + "original shape xq: torch.Size([4, 16, 8, 64])\n", + "ajusted shape xq: torch.Size([4, 16, 8, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 16, 1, 32])\n", + "torch.Size([4, 16, 512]) torch.Size([4, 16, 2, 64]) torch.Size([4, 16, 2, 64])\n" + ] + } + ], + "source": [ + "attn = Attention(LMConfig())\n", + "x = torch.randn((4, 16, 512))\n", + "print(x.shape)\n", + "pos_cis = precompute_pos_cis(64, 16)\n", + "print(pos_cis.shape)\n", + "output, past_kv = attn(x, pos_cis=pos_cis, use_cache=True)\n", + "print(output.shape, past_kv[0].shape, past_kv[1].shape)" + ] + }, + { + "cell_type": "markdown", + "id": "495f1e81-36de-4e94-8ae4-69490f62d15f", + "metadata": {}, + "source": [ + "### FeedForward Network\n", + "\n", + "前向传播神经网络接收来自注意力层的输出,并对其做进一步的线性变换,以捕获更复杂的特征和表示。" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "a16f3ece-3b4e-42aa-9b88-1a7193dd2bf0", + "metadata": {}, + "outputs": [], + "source": [ + "class FeedForward(nn.Module):\n", + " def __init__(self, config: LMConfig):\n", + " super().__init__()\n", + " if config.hidden_dim is None:\n", + " hidden_dim = 4 * config.dim\n", + " hidden_dim = int(2 * hidden_dim / 3)\n", + " config.hidden_dim = config.multiple_of * ((hidden_dim + config.multiple_of - 1) // config.multiple_of)\n", + " self.w1 = nn.Linear(config.dim, config.hidden_dim, bias=False)\n", + " self.w2 = nn.Linear(config.hidden_dim, config.dim, bias=False)\n", + " self.w3 = nn.Linear(config.dim, config.hidden_dim, bias=False)\n", + " self.dropout = nn.Dropout(config.dropout)\n", + "\n", + " def forward(self, x):\n", + " return self.dropout(self.w2(F.silu(self.w1(x) * self.w3(x))))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "4fe2abd9-0c40-44be-b64d-8b0caa28604b", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "torch.Size([4, 16, 512])" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "ffn = FeedForward(LMConfig())\n", + "x = torch.randn((4, 16, 512))\n", + "output = ffn(x)\n", + "output.shape" + ] + }, + { + "cell_type": "markdown", + "id": "2317829c-39b8-44d5-beb2-1b9727bc35c8", + "metadata": {}, + "source": [ + "### MiniMind Block\n", + "\n", + "到目前为止,,已经完成了 Attention Layer 和 FeedForward Layer 的构建,所有必须的组件都已经具备,我们着手构建一个 MiniMind Block" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "d6c224a4-7ba5-4cf8-9297-311c660f5764", + "metadata": {}, + "outputs": [], + "source": [ + "class MiniMindBlock(nn.Module):\n", + " def __init__(self, layer_id: int, config: LMConfig):\n", + " super().__init__()\n", + " self.n_heads = config.n_heads\n", + " self.dim = config.dim\n", + " self.head_dim = config.dim // config.n_heads # number of GQA heads\n", + " self.attention = Attention(config)\n", + "\n", + " self.layer_id = layer_id\n", + " self.attention_norm = RMSNorm(config.dim, eps=config.norm_eps)\n", + " self.ffn_norm = RMSNorm(config.dim, eps=config.norm_eps)\n", + " self.feed_forward = FeedForward(config)\n", + "\n", + " def forward(self, x, pos_cis, past_key_value=None, use_cache=False):\n", + " h_attn, past_kv = self.attention(\n", + " self.attention_norm(x), # pre-normed x\n", + " pos_cis,\n", + " past_key_value=past_key_value,\n", + " use_cache=use_cache\n", + " )\n", + " h = x + h_attn # residual connection\n", + " out = h + self.feed_forward(self.ffn_norm(h)) # feed forward + residual connection\n", + " return out, past_kv" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "id": "d23b4461-534c-4f6d-a00b-cdcfbd4d7bec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "original shape xq: torch.Size([4, 16, 8, 64])\n", + "ajusted shape xq: torch.Size([4, 16, 8, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 16, 1, 32])\n" + ] + }, + { + "data": { + "text/plain": [ + "(torch.Size([4, 16, 512]),\n", + " torch.Size([4, 16, 2, 64]),\n", + " torch.Size([4, 16, 2, 64]))" + ] + }, + "execution_count": 12, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "miniblock = MiniMindBlock(1, LMConfig())\n", + "x = torch.randn((4, 16, 512))\n", + "pos_cis = precompute_pos_cis(64, 16)\n", + "out, past_kv = miniblock(x, pos_cis, use_cache=True)\n", + "out.shape, past_kv[0].shape, past_kv[1].shape" + ] + }, + { + "cell_type": "markdown", + "id": "f9bb3b2e-4d1d-4721-9908-546f3c49dde3", + "metadata": {}, + "source": [ + "### MiniMindLM\n", + "\n", + "以 MiniMind Block 为基本组件,我们可以对该 LLM 进行最后组装!" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "id": "7267245b-f13a-4bcf-9358-5fdd89106153", + "metadata": {}, + "outputs": [], + "source": [ + "from transformers import PreTrainedModel\n", + "from transformers.modeling_outputs import CausalLMOutputWithPast\n", + "\n", + "class MOEFeedForward():\n", + " \"\"\"Place holder\"\"\"\n", + " pass\n", + "\n", + "class MiniMindLM(PreTrainedModel):\n", + " \"\"\"Notice: To simplify notebook run, set n_layers to 2\"\"\"\n", + " config_class = LMConfig\n", + "\n", + " def __init__(self, params: LMConfig = None):\n", + " self.params = params or LMConfig()\n", + " super().__init__(self.params)\n", + " self.vocab_size, self.n_layers = params.vocab_size, params.n_layers\n", + " # 映射:词表维度 -> 嵌入维度\n", + " self.tok_embeddings = nn.Embedding(params.vocab_size, params.dim)\n", + " self.dropout = nn.Dropout(params.dropout)\n", + " self.layers = nn.ModuleList([MiniMindBlock(1, params) for l in range(self.n_layers)])\n", + " self.norm = RMSNorm(params.dim, eps = params.norm_eps)\n", + " self.output = nn.Linear(params.dim, params.vocab_size, bias=False)\n", + " self.tok_embeddings.weight = self.output.weight\n", + " self.register_buffer(\n", + " \"pos_cis\",\n", + " precompute_pos_cis(dim=params.dim // params.n_heads, theta=params.rope_theta),\n", + " persistent=False\n", + " )\n", + " self.OUT = CausalLMOutputWithPast()\n", + "\n", + " def forward(self,\n", + " input_ids: Optional[torch.Tensor] = None,\n", + " past_key_values: Optional[List[Tuple[torch.Tensor, torch.Tensor]]] = None,\n", + " use_cache: bool = False,\n", + " **args):\n", + " past_key_values = past_key_values or [None] * len(self.layers) # save history KV\n", + " start_pos = args.get('start_pos', 0)\n", + " h = self.dropout(self.tok_embeddings(input_ids))\n", + " pos_cis = self.pos_cis[start_pos: start_pos + input_ids.size(1)]\n", + " past_kvs = []\n", + " for l, layer in enumerate(self.layers):\n", + " h, past_kv = layer(\n", + " h, pos_cis,\n", + " past_key_value=past_key_values[l],\n", + " use_cache=use_cache\n", + " )\n", + " past_kvs.append(past_kv)\n", + " logits = self.output(self.norm(h))\n", + " aux_loss = sum(l.feed_forward.aux_loss for l in self.layers if isinstance(l.feed_forward, MOEFeedForward)) # MOE 辅助损失\n", + " self.OUT.__setitem__('logits', logits)\n", + " self.OUT.__setitem__('aux_loss', aux_loss)\n", + " self.OUT.__setitem__('past_key_value', past_kvs)\n", + " return self.OUT\n", + "\n", + " @torch.inference_mode()\n", + " def generate(self, input_ids, eos_token_id=2, max_new_tokens=512, temperature=0.75, top_p=0.90,\n", + " stream=False, rp=1, use_cache=True, pad_token_id=0, **args):\n", + " # 流式生成 (返回生成器,自由控制输出)\n", + " if stream:\n", + " return self._stream(input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, **args)\n", + " # 直接生成 (一步到位)\n", + " generated = []\n", + " for i in range(input_ids.size(0)):\n", + " non_pad = input_ids[i][input_ids[i] != pad_token_id].unsqueeze(0)\n", + " out = self._stream(non_pad, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, **args)\n", + " tokens_list = [tokens[:, -1:] for tokens in out]\n", + " gen = torch.cat(tokens_list, dim=-1) if tokens_list else non_pad\n", + " full_sequence = torch.cat([non_pad, gen], dim=-1)\n", + " generated.append(full_sequence)\n", + " max_length = max(seq.size(1) for seq in generated)\n", + " generated = [\n", + " torch.cat([seq, torch.full((1, max_length - seq.size(1)), pad_token_id, dtype=seq.dtype, device=seq.device)],dim=-1) \n", + " for seq in generated\n", + " ]\n", + " return torch.cat(generated, dim=0)\n", + "\n", + " def _stream(self, input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, **args):\n", + " start, first_seq, past_kvs = input_ids.shape[1], True, None\n", + " while input_ids.shape[1] < max_new_tokens - 1:\n", + " if first_seq or not use_cache: # 若第一次生成序列 or 无 KV Cache, 每次生成传入整个 token id 序列\n", + " out, first_seq = self(input_ids, past_key_values=past_kvs, use_cache=use_cache, **args), False\n", + " else: # 若非第一次生成 and 有 KV Cache,每次传入最后一个 token id 与 KV Cache 进行推理加速\n", + " out = self(input_ids[:, -1:], past_key_values=past_kvs, use_cache=use_cache,\n", + " start_pos=input_ids.shape[1] - 1, **args)\n", + " logits, past_kvs = out.logits[:, -1, :], out.past_key_values # logits.shape: (batch_size, seq_len, embed_dim),获取最后一位 logits\n", + " logits[:, list(set(input_ids.tolist()[0]))] /= rp # 对生成 token 进行惩罚,降低后续重复生成几率\n", + " logits /= (temperature + 1e-9) # 调整温度,控制生成多样性\n", + " if top_p is not None and top_p < 1.0: # top-p 采样\n", + " sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1)\n", + " sorted_probs = F.softmax(sorted_logits, dim=-1)\n", + " cumulative_probs = torch.cumsum(sorted_probs, dim=-1)\n", + " sorted_indices_to_remove = cumulative_probs > top_p\n", + " sorted_indices_to_remove[:, 1:] = sorted_indices_to_remove[:, :-1].clone()\n", + " sorted_indices_to_remove[:, 0] = False\n", + " indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove)\n", + " logits[indices_to_remove] = -float('Inf')\n", + " input_ids_next = torch.multinomial(F.softmax(logits, dim=-1), num_samples=1) # 从保留的 token 中采样\n", + " input_ids = torch.cat((input_ids, input_ids_next), dim=1)\n", + " yield input_ids[:, start:]\n", + " if input_ids_next.item() == eos_token_id:\n", + " break" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "id": "dc7b4d79-a3aa-4143-aa5c-2abce34bc96c", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "original shape xq: torch.Size([1, 4, 8, 64])\n", + "ajusted shape xq: torch.Size([1, 4, 8, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 4, 1, 32])\n" + ] + }, + { + "data": { + "text/plain": [ + "(torch.Size([1, 4, 6400]), 0, 1)" + ] + }, + "execution_count": 14, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "MiniMind = MiniMindLM(LMConfig())\n", + "input_ids = torch.Tensor([1, 3, 5, 7]).long().reshape(1, 4)\n", + "OUT = MiniMind(input_ids, use_cache=True)\n", + "OUT.logits.shape, OUT.aux_loss, len(OUT.past_key_value)" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "id": "3480a860-e934-43de-a621-8063f0bdea2f", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "original shape xq: torch.Size([1, 4, 8, 64])\n", + "ajusted shape xq: torch.Size([1, 4, 8, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 4, 1, 32])\n", + "original shape xq: torch.Size([1, 1, 8, 64])\n", + "ajusted shape xq: torch.Size([1, 1, 8, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 1, 1, 32])\n", + "original shape xq: torch.Size([1, 1, 8, 64])\n", + "ajusted shape xq: torch.Size([1, 1, 8, 32])\n", + "ajusted shape pos_cis: torch.Size([1, 1, 1, 32])\n", + "torch.Size([1, 7]) tensor([[ 1, 3, 5, 7, 4867, 4289, 6268]])\n" + ] + } + ], + "source": [ + "# 我们让 MiniMind 根据我们设计的 四个输入 token 生成输出\n", + "out = MiniMind.generate(input_ids, max_new_tokens=8, use_cache=True)\n", + "print(out.shape, out)" + ] + }, + { + "cell_type": "markdown", + "id": "b0e4ef5f-1358-4ffb-a150-61141ac3062f", + "metadata": {}, + "source": [ + "## Minimind MOE Model" + ] + }, + { + "cell_type": "markdown", + "id": "e54ced18-db3a-478b-9578-9825634f3d66", + "metadata": {}, + "source": [ + "@TODO" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebook/2_dataset.ipynb b/notebook/2_dataset.ipynb new file mode 100644 index 0000000..3a4499a --- /dev/null +++ b/notebook/2_dataset.ipynb @@ -0,0 +1,574 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "44db347d-fef7-4e56-aeab-ce55bdab8eab", + "metadata": {}, + "source": [ + "# 2-Dataset\n", + "\n", + "到这里我们便完成了对于 MiniMind Tokenizer 和 Model 部分的全部了解,我们所熟悉的大语言模型正是由这个组件构成的,接下来,我们需要对大模型训练所使用的数据集结构有个基本的认识。\n", + "\n", + "想要训练一个能够正常对话,并且符合人类对话偏好的大模型一般需要经过以下几个训练阶段:\n", + "\n", + "- 预训练(Pre-training)\n", + "- 有监督微调(Supervised Fine-tuning,SFT)\n", + "- 人类反馈强化学习(Reinforcement Learning from Human Feedback,RLHF)\n", + "\n", + "在不同训练阶段使用的数据集有所不同,下面会从 MiniMind 代码出发进行介绍和解读。" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "e41156fd-ca0e-4083-a7a4-a5f98019ab88", + "metadata": {}, + "outputs": [], + "source": [ + "import json\n", + "import random\n", + "import re\n", + "\n", + "import pandas as pd\n", + "import numpy as np\n", + "from torch.utils.data import Dataset, DataLoader\n", + "import torch\n", + "from sklearn.model_selection import train_test_split\n", + "import os\n", + "import ast\n", + "from transformers import AutoTokenizer\n", + "\n", + "os.environ[\"TOKENIZERS_PARALLELISM\"] = \"false\"" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "27d18ad2-3f71-435c-af03-ad9d347aa048", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "6400\n" + ] + } + ], + "source": [ + "# 从 ../model 目录加载分词器\n", + "\n", + "tokenizer = AutoTokenizer.from_pretrained('../model/minimind_tokenizer')\n", + "print(tokenizer.vocab_size)" + ] + }, + { + "cell_type": "markdown", + "id": "ad08fe92-1d04-4b9c-b689-6ad9d763a7c2", + "metadata": {}, + "source": [ + "## 预训练数据集\n", + "\n", + "预训练是模型在大规模语料上进行无监督学习的训练阶段,在该阶段,模型主要学习下一词预测的能力,简单的来说就是学会说话,而不是胡言乱语。因此,该阶段训练的模型不会具有问答能力,而是根据用户输入进行简单的词语接龙。\n", + "\n", + "我们可以看一看预训练的数据集格式:\n", + "\n", + "```\n", + "{\"text\": \"如何才能摆脱拖延症? 治愈拖延症并不容易,但以下建议可能有所帮助...\"}\n", + "```\n", + "\n", + "为了降低该 demo 的运行门槛,在 `./demo` 文件夹下提供了包含两条训练数据的 `pretrain_data.jsonl` 文件作为熟悉训练流程的数据集 demo。" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "cdcac4aa-0ced-4a74-bba0-bc248923c7bc", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Row 1: {'text': 'LLM首先要学习的并非直接与人交流,而是让网络参数中充满知识的墨水,“墨水” 理论上喝的越饱越好,产生大量的对世界的知识积累。 预训练就是让Model先埋头苦学大量基本的知识,例如从Wiki百科、新闻、书籍整理大规模的高质量训练数据。 这个过程是“无监督”的,即人类不需要在过程中做任何“有监督”的校正,而是由模型自己从大量文本中总结规律学习知识点。 模型此阶段目的只有一个:学会词语接龙。例如我们输入“秦始皇”四个字,它可以接龙“是中国的第一位皇帝”。'}\n", + "\n", + "Row 2: {'text': '经过预训练,LLM此时已经掌握了大量知识,然而此时它只会无脑地词语接龙,还不会与人聊天。 SFT阶段就需要把半成品LLM施加一个自定义的聊天模板进行微调。 例如模型遇到这样的模板【问题->回答,问题->回答】后不再无脑接龙,而是意识到这是一段完整的对话结束。 称这个过程为指令微调,就如同让已经学富五车的「牛顿」先生适应21世纪智能手机的聊天习惯,学习屏幕左侧是对方消息,右侧是本人消息这个规律。 在训练时,MiniMind的指令和回答长度被截断在512,是为了节省显存空间。就像我们学习时,会先从短的文章开始,当学会写作200字作文后,800字文章也可以手到擒来。 在需要长度拓展时,只需要准备少量的2k/4k/8k长度对话数据进行进一步微调即可(此时最好配合RoPE-NTK的基准差值)。'}\n", + "\n" + ] + } + ], + "source": [ + "# 我们可以查看一下 demo 中提供的数据\n", + "path_pretrain = './demo/pretrain_data.jsonl'\n", + "\n", + "with open(path_pretrain, 'r', encoding='utf-8') as f:\n", + " for line_num, line in enumerate(f, 1):\n", + " data = json.loads(line.strip())\n", + " print(f'Row {line_num}: {data}\\n')" + ] + }, + { + "cell_type": "markdown", + "id": "0c0707e3-3ff9-424b-a5be-dab9db55aa44", + "metadata": {}, + "source": [ + "我们知道,构建一个深度学习数据集需要继承 `torch.utils.data.dataset`,并构建 DataLoader 数据迭代器进行迭代访问。下面,我们来看看 MiniMind 是如何抽象一个预训练数据集的。" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "fb2458e3-3661-40de-96be-aa036291a8fb", + "metadata": {}, + "outputs": [], + "source": [ + "class PretrainDataset(Dataset):\n", + " def __init__(self, data_path, tokenizer, max_length=512):\n", + " super().__init__()\n", + " self.tokenizer = tokenizer\n", + " self.max_length = max_length\n", + " self.samples = self.load_data(data_path)\n", + "\n", + " def load_data(self, path):\n", + " \"\"\"按行读取 jsonl 文件,并存储在列表中\"\"\"\n", + " samples = []\n", + " with open(path, 'r', encoding='utf-8') as f:\n", + " for line_num, line in enumerate(f, 1):\n", + " data = json.loads(line.strip())\n", + " samples.append(data)\n", + " return samples\n", + "\n", + " def __len__(self):\n", + " return len(self.samples)\n", + "\n", + " def __getitem__(self, index):\n", + " sample = self.samples[index]\n", + "\n", + " text = f\"{self.tokenizer.bos_token}{str(sample['text'])}{self.tokenizer.eos_token}\"\n", + " # print(text) # uncomment to see formating prompt\n", + " encoding = self.tokenizer(\n", + " text,\n", + " max_length=self.max_length,\n", + " padding='max_length',\n", + " truncation=True,\n", + " return_tensors='pt'\n", + " )\n", + " # print(encoding) # uncomment to see encoding result\n", + " input_ids = encoding.input_ids.squeeze()\n", + " loss_mask = (input_ids != self.tokenizer.pad_token_id) # mask to ignore loss on pad token\n", + "\n", + " X = torch.tensor(input_ids[:-1], dtype=torch.long) # token not included\n", + " Y = torch.tensor(input_ids[1:], dtype=torch.long) # token not included\n", + " loss_mask = torch.tensor(loss_mask[1:], dtype=torch.long) # align to tensor X\n", + " return X, Y, loss_mask" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "677a1bbb-0df7-45e5-a3ed-d4431cddf43e", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n" + ] + } + ], + "source": [ + "pretrain_dataset = PretrainDataset(path_pretrain, tokenizer)\n", + "print(len(pretrain_dataset))\n", + "# x, y, lm = pretrain_dataset[0]\n", + "# print(x.shape, y.shape, lm.shape)" + ] + }, + { + "cell_type": "markdown", + "id": "ebb160cb-0e96-45a7-a1c0-be145895e70f", + "metadata": {}, + "source": [ + "## 有监督微调数据集\n", + "\n", + "有监督微调(Supervised Fine Tuning,SFT)对预训练后得到的基座 LLM 施加一个自定义聊天模板进行微调,由于在这一阶段,模型训练的目标是根据用户指令生成响应(构建问答体系),故又称为指令微调。\n", + "\n", + "我们可以看一看有监督微调的数据集格式:\n", + "\n", + "```\n", + "{\n", + " \"conversations\": [\n", + " {\"role\": \"user\", \"content\": \"你好\"},\n", + " {\"role\": \"assistant\", \"content\": \"你好!\"},\n", + " {\"role\": \"user\", \"content\": \"再见\"},\n", + " {\"role\": \"assistant\", \"content\": \"再见!\"}\n", + " ]\n", + "}\n", + "```\n", + "\n", + "为了降低该 demo 的运行门槛,在 `./demo` 文件夹下提供了包含两条 conversation 问答数据的 `sft_data.jsonl` 文件作为熟悉训练流程的数据集 demo。" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "7e025e25-c937-4468-ae66-b32447440658", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Row 1: {'conversations': [{'role': 'user', 'content': '你好吗?'}, {'role': 'assistant', 'content': '我很好,谢谢!你呢?'}, {'role': 'user', 'content': '我也很好,谢谢!'}, {'role': 'assistant', 'content': '太好了!祝你今天愉快!'}]}\n", + "\n", + "Row 2: {'conversations': [{'role': 'user', 'content': '你喜欢什么运动?'}, {'role': 'assistant', 'content': '我喜欢跑步和游泳。你呢?'}, {'role': 'user', 'content': '我喜欢打篮球!'}, {'role': 'assistant', 'content': '篮球很棒!是一个很好的团队运动。'}]}\n", + "\n" + ] + } + ], + "source": [ + "# 我们可以查看一下 demo 中提供的数据\n", + "path_sft = './demo/sft_data.jsonl'\n", + "\n", + "with open(path_sft, 'r', encoding='utf-8') as f:\n", + " for line_num, line in enumerate(f, 1):\n", + " data = json.loads(line.strip())\n", + " print(f'Row {line_num}: {data}\\n')" + ] + }, + { + "cell_type": "markdown", + "id": "5f988233-530f-4ddf-8d64-9532442a2535", + "metadata": {}, + "source": [ + "接下来,我们尝试构造一个数据集对象,实现对 sft 格式数据的读取与处理。" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "8b25f0c6-b5b5-47b1-974f-15a2f0d2760e", + "metadata": {}, + "outputs": [], + "source": [ + "class SFTDataset(Dataset):\n", + " def __init__(self, jsonl_path, tokenizer, max_length=512):\n", + " super().__init__()\n", + " self.tokenizer = tokenizer\n", + " self.max_length = max_length\n", + " self.samples = self.load_data(jsonl_path)\n", + " self.bos_id = tokenizer('assistant\\n', add_special_tokens=False).input_ids # set bos token\n", + " self.eos_id = tokenizer('\\n', add_special_tokens=False).input_ids # set eos token\n", + "\n", + " def load_data(self, path):\n", + " samples = []\n", + " with open(path, 'r', encoding='utf-8') as f:\n", + " for line_num, line in enumerate(f, 1):\n", + " data = json.loads(line.strip())\n", + " samples.append(data)\n", + " return samples\n", + " \n", + " def __len__(self):\n", + " return len(self.samples)\n", + "\n", + " def _create_chat_prompt(self, conversations):\n", + " \"\"\"构建符合 ChatML 格式的对话\"\"\"\n", + " messages = []\n", + " for i, turn in enumerate(conversations): # for each speaker in one conversation\n", + " role = 'user' if i % 2 == 0 else 'assistant'\n", + " messages.append({\"role\": role, \"content\": turn['content']})\n", + " return self.tokenizer.apply_chat_template(\n", + " messages,\n", + " tokenize=False,\n", + " add_generation_prompt=False\n", + " )\n", + "\n", + " def _generate_loss_mask(self, input_ids):\n", + " loss_mask = [0] * len(input_ids)\n", + " i = 0\n", + " while i < len(input_ids):\n", + " if input_ids[i:i + len(self.bos_id)] == self.bos_id: # check if reach bos token\n", + " ########### find content start & end point ###########\n", + " start = i + len(self.bos_id) # set start point at the end of bos token\n", + " end = start\n", + " while end < len(input_ids):\n", + " if input_ids[end:end + len(self.eos_id)] == self.eos_id: # check if reach eos token\n", + " break\n", + " end += 1\n", + " ######################################################\n", + " for j in range(start + 1, min(end + len(self.eos_id) + 1, self.max_length)): # ignore tokens that reach max input length\n", + " loss_mask[j] = 1\n", + " i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids) # update i to exit current conversation turn\n", + " else:\n", + " i += 1\n", + " return loss_mask\n", + " \n", + " def __getitem__(self, index):\n", + " sample = self.samples[index]\n", + " prompt = self._create_chat_prompt(sample['conversations'])\n", + " print(prompt) # uncomment to see formating prompt\n", + " input_ids = self.tokenizer(prompt).input_ids[:self.max_length] # encode input\n", + " input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids)) # encode padding\n", + " print(input_ids) # uncomment to see encoded prompt\n", + " \n", + " loss_mask = self._generate_loss_mask(input_ids)\n", + "\n", + " X = torch.tensor(input_ids[:-1], dtype=torch.long)\n", + " Y = torch.tensor(input_ids[1:], dtype=torch.long)\n", + " loss_mask = torch.tensor(loss_mask[1:], dtype=torch.long) # align with pred pos\n", + "\n", + " return X, Y, loss_mask" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "67a62dbf-6a50-4faf-8ad1-5228eb8aa7c9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n", + "system\n", + "你是 MiniMind,是一个有用的人工智能助手。\n", + "user\n", + "你好吗?\n", + "assistant\n", + "我很好,谢谢!你呢?\n", + "user\n", + "我也很好,谢谢!\n", + "assistant\n", + "太好了!祝你今天愉快!\n", + "\n", + "[1, 85, 736, 201, 608, 345, 562, 261, 75, 47, 807, 270, 1589, 400, 411, 1946, 740, 1728, 945, 1184, 286, 2, 201, 1, 320, 275, 201, 5134, 2235, 814, 2, 201, 1, 1078, 538, 501, 201, 397, 5675, 270, 4191, 2207, 608, 3274, 814, 2, 201, 1, 320, 275, 201, 397, 698, 5675, 270, 4191, 2207, 2, 201, 1, 1078, 538, 501, 201, 1633, 587, 446, 2207, 5625, 608, 4668, 5295, 1693, 2207, 2, 201, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]\n", + "torch.Size([511]) torch.Size([511]) torch.Size([511])\n" + ] + } + ], + "source": [ + "sft_dataset = SFTDataset(path_sft, tokenizer)\n", + "print(len(sft_dataset))\n", + "x, y, lm = sft_dataset[0]\n", + "print(x.shape, y.shape, lm.shape)\n", + "# print(lm) # 打印 loss mask,你会发现在序列中有两处以 1 填充的序列,这是因为我们在一个 conversation 中开展了两轮对话,其中只有 assistant 回复计算损失" + ] + }, + { + "cell_type": "markdown", + "id": "a7094ff6-4657-4483-8c34-785ffe9adbd4", + "metadata": {}, + "source": [ + "## 人类反馈强化学习数据集\n", + "\n", + "在 MiniMind 项目中,采用直接偏好优化(Direct Parameter Optimization,DPO)训练大模型对齐人类偏好。在这一训练阶段,模型将会根据提供的问答正反例进行偏好优化,从而降低让人类不满意的答案出现的几率。\n", + "\n", + "与PPO(Proximal Policy Optimization)这种需要奖励模型、价值模型的RL算法不同; DPO通过推导PPO奖励模型的显式解,把在线奖励模型换成离线数据,Ref模型输出可以提前保存。 DPO性能几乎不变,只用跑 actor_model 和 ref_model 两个模型,大大节省显存开销和增加训练稳定性。\n", + "\n", + "我们可以看一看有监督微调的数据集格式:\n", + "\n", + "```\n", + "{\n", + " \"chosen\": [\n", + " {\"content\": \"Query\", \"role\": \"user\"}, \n", + " {\"content\": \"good answer\", \"role\": \"assistant\"}\n", + " ], \n", + " \"rejected\": [\n", + " {\"content\": \"Query\", \"role\": \"user\"}, \n", + " {\"content\": \"bad answer\", \"role\": \"assistant\"}\n", + " ]\n", + "}\n", + "```\n", + "\n", + "为了降低该 demo 的运行门槛,在 ./demo 文件夹下提供了包含两条 conversation 问答数据的 sft_data.jsonl 文件作为熟悉训练流程的数据集 demo。" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "81682b5f-0d3e-4fa2-aec4-0be2b19d5c05", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Row 1: {'conversations': [{'role': 'user', 'content': '你好吗?'}, {'role': 'assistant', 'content': '我很好,谢谢!你呢?'}, {'role': 'user', 'content': '我也很好,谢谢!'}, {'role': 'assistant', 'content': '太好了!祝你今天愉快!'}]}\n", + "\n", + "Row 2: {'conversations': [{'role': 'user', 'content': '你喜欢什么运动?'}, {'role': 'assistant', 'content': '我喜欢跑步和游泳。你呢?'}, {'role': 'user', 'content': '我喜欢打篮球!'}, {'role': 'assistant', 'content': '篮球很棒!是一个很好的团队运动。'}]}\n", + "\n" + ] + } + ], + "source": [ + "# 我们可以查看一下 demo 中提供的数据\n", + "path_dpo = './demo/dpo_data.jsonl'\n", + "\n", + "with open(path_sft, 'r', encoding='utf-8') as f:\n", + " for line_num, line in enumerate(f, 1):\n", + " data = json.loads(line.strip())\n", + " print(f'Row {line_num}: {data}\\n')" + ] + }, + { + "cell_type": "markdown", + "id": "ebb54557-54f4-4004-8a7b-443b8671002b", + "metadata": {}, + "source": [ + "接下来,我们尝试构造 json 对象,实现对 dpo 格式数据的读取和处理" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "efa98777-5d3a-4b3f-a9fb-e0f05e4af81d", + "metadata": {}, + "outputs": [], + "source": [ + "class DPODataset(Dataset):\n", + " def __init__(self, file_path, tokenizer, max_length=512):\n", + " super().__init__()\n", + " self.tokenizer = tokenizer\n", + " self.max_length = max_length\n", + " self.padding = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0\n", + " self.bos_id = tokenizer('assistant\\n', add_special_tokens=False).input_ids # content prefix\n", + " self.eos_id = tokenizer('\\n', add_special_tokens=False).input_ids # content suffix\n", + " with open(file_path, 'r', encoding='utf-8') as f: # load data\n", + " self.data = []\n", + " for line in f:\n", + " line = line.strip()\n", + " obj = json.loads(line)\n", + " self.data.append(obj)\n", + "\n", + " def __len__(self):\n", + " return len(self.data)\n", + "\n", + " def _generate_loss_mask(self, input_ids):\n", + " \"\"\"此处的损失掩码生成函数与 SFT 阶段逻辑一致\"\"\"\n", + " loss_mask = [0] * len(input_ids)\n", + " i = 0\n", + " while i < len(input_ids):\n", + " if input_ids[i:i + len(self.bos_id)] == self.bos_id:\n", + " start = i + len(self.bos_id)\n", + " end = start\n", + " while end < len(input_ids):\n", + " if input_ids[end:end + len(self.eos_id)] == self.eos_id:\n", + " break\n", + " end += 1\n", + " for j in range(start + 1, min(end + len(self.eos_id) + 1, self.max_length)):\n", + " loss_mask[j] = 1\n", + " i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids)\n", + " else:\n", + " i += 1\n", + " return loss_mask\n", + " \n", + " def __getitem__(self, index):\n", + " item = self.data[index]\n", + " chosen = item['chosen'] # 一个 list,里面包含若干 {role, content}\n", + " rejected = item['rejected'] # 同上\n", + " # format prompt\n", + " chosen_prompt = self.tokenizer.apply_chat_template(\n", + " chosen, tokenize=False, add_generation_prompt=False\n", + " )\n", + " print(chosen_prompt) # uncomment to see formating prompt\n", + " rejected_prompt = self.tokenizer.apply_chat_template(\n", + " rejected, tokenize = False, add_gerneration_prompt=False\n", + " )\n", + " print(rejected_prompt) # uncomment to see formating prompt\n", + " # tokenize\n", + " chosen_encoding = self.tokenizer(\n", + " chosen_prompt, truncation=True, max_length=self.max_length, padding='max_length'\n", + " )\n", + " rejected_encoding = self.tokenizer(\n", + " rejected_prompt, truncation=True, max_length=self.max_length, padding='max_length'\n", + " )\n", + " # generate loss mask\n", + " chosen_input_ids = chosen_encoding['input_ids']\n", + " chosen_loss_mask = self._generate_loss_mask(chosen_input_ids)\n", + " rejected_input_ids = rejected_encoding['input_ids']\n", + " rejected_loss_mask = self._generate_loss_mask(rejected_input_ids)\n", + " # same as sft / pretrain\n", + " x_chosen = torch.tensor(chosen_input_ids[:-1], dtype=torch.long)\n", + " y_chosen = torch.tensor(chosen_input_ids[1:], dtype=torch.long)\n", + " mask_chosen = torch.tensor(chosen_loss_mask[1:], dtype=torch.long)\n", + " x_rejected = torch.tensor(rejected_input_ids[:-1], dtype=torch.long)\n", + " y_rejected = torch.tensor(rejected_input_ids[1:], dtype=torch.long)\n", + " mask_rejected = torch.tensor(rejected_loss_mask[1:], dtype=torch.long)\n", + "\n", + " return {\n", + " 'x_chosen': x_chosen,\n", + " 'y_chosen': y_chosen,\n", + " 'mask_chosen': mask_chosen,\n", + " 'x_rejected': x_rejected,\n", + " 'y_rejected': y_rejected,\n", + " 'mask_rejected': mask_rejected\n", + " }" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "5ba205c4-0dd2-48fd-8aa2-26a42afc90d8", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "2\n", + "system\n", + "你是 MiniMind,是一个有用的人工智能助手。\n", + "user\n", + "你好吗?\n", + "assistant\n", + "我很好,谢谢!你呢?\n", + "user\n", + "今天过得怎么样?\n", + "assistant\n", + "挺好的,去跑步了,心情不错。\n", + "\n", + "system\n", + "你是 MiniMind,是一个有用的人工智能助手。\n", + "user\n", + "你好吗?\n", + "assistant\n", + "不好,我很累。\n", + "user\n", + "你喜欢什么运动?\n", + "assistant\n", + "我不喜欢运动,没兴趣。\n", + "\n" + ] + } + ], + "source": [ + "dpo_dataset = DPODataset(path_dpo, tokenizer)\n", + "print(len(dpo_dataset))\n", + "res = dpo_dataset[0]\n", + "# 如有需要,请自主查看 res 中的元素" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebook/3_pretrain.ipynb b/notebook/3_pretrain.ipynb new file mode 100644 index 0000000..5f3b82a --- /dev/null +++ b/notebook/3_pretrain.ipynb @@ -0,0 +1,372 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "aba635c1-cdcd-4d00-a123-00045edbd10f", + "metadata": {}, + "source": [ + "# 3-Pretrain\n", + "\n", + "预训练是模型经历的第一个阶段,在该阶段,模型将会吸收知识,学习尽可能正确的下一词语预测范式。在这个笔记本中,我们仅对预训练的训练流程进行展示和学习,因此只给出必要的代码片段,如 wandb 和 ddp 不会在此笔记本中涉及。\n", + "\n", + "此笔记本的完整实现见主仓库 `/minimind/train_pretrain.py`" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "98cb706f-2e03-4d8e-8e45-3663c9ab3b64", + "metadata": {}, + "outputs": [], + "source": [ + "# 导入依赖\n", + "import os\n", + "import platform\n", + "import argparse\n", + "import time\n", + "import math\n", + "import warnings\n", + "import pandas as pd\n", + "import torch\n", + "import torch.distributed as dist\n", + "from torch import optim, nn\n", + "from torch.nn.parallel import DistributedDataParallel\n", + "from torch.optim.lr_scheduler import CosineAnnealingLR\n", + "from torch.utils.data import DataLoader, DistributedSampler\n", + "from contextlib import nullcontext\n", + "\n", + "from transformers import AutoTokenizer\n", + "\n", + "from demo.model import MiniMindLM\n", + "from demo.LMConfig import LMConfig\n", + "from demo.dataset import PretrainDataset" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c3b6f015-0e5c-4e88-bb0d-32092993933f", + "metadata": {}, + "outputs": [], + "source": [ + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "markdown", + "id": "a46c9b5b-12eb-4cad-a859-c5101cdcba82", + "metadata": {}, + "source": [ + "## 可选参数设置\n", + "\n", + "首先,查看训练的可选参数,这些参数在实际使用时通过命令行导入,为了保持笔记本的易用性,选择用 class 进行包装" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "3f07e381-961e-4a4f-9f37-ff2f0bc2918c", + "metadata": {}, + "outputs": [], + "source": [ + "class args:\n", + " # out_dir: str = \"out\" # pytorch 格式权重文件保存位置 我们只展示训练过程 所以不使用\n", + " epochs: int = 1 # 训练轮数\n", + " batch_size: int = 2 # pretrain 数据集仅两个样本,设置 batch 为 2\n", + " learning_rate: float = 5e-4 # 学习率\n", + " device: str = 'cuda' if torch.cuda.is_available() else 'cpu'\n", + " dtype: str = 'bfloat16' # 16 bit 浮点数:8 bit 指数 + 7 bit 尾数\n", + " # use_wandb: bool = False # 是否使用 wandb 我们不使用\n", + " wandb_project: str = 'MiniMind-Notebook'\n", + " num_workers: int = 1 # 工作进程数\n", + " # ddp:bool = False # 单机多卡\n", + " accumulation_steps: int = 1 # 梯度累积步数\n", + " grad_clip: float = 1.0 # 梯度剪裁\n", + " warmup_iters: int = 0 # 学习率热启动\n", + " log_interval: int = 1 # 每一步打印日志 仅用于观察\n", + " # save_interval: int = 100 # checkpoint 保存点 我们不使用\n", + " local_rank: int = 1 # device 设备号\n", + " dim: int = 512 # 词嵌入维度 模型超参数\n", + " n_layers: int = 2 # MiniMind Block 数量 模型超参数\n", + " max_seq_len: int = 512 # 序列长度阈值\n", + " use_moe: bool = False # 是否启用混合专家\n", + " data_path: str = './demo/pretrain_data.jsonl' # 数据集路径" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "8f73286e-48a0-4a5b-b7c4-8c5fefea5f14", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'cuda'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "args.device" + ] + }, + { + "cell_type": "markdown", + "id": "7e4b0ea8-0f41-47fe-8125-93629d03d451", + "metadata": {}, + "source": [ + "## 初始化训练\n", + "\n", + "\n", + "接下来,我们对一些重要模块进行初始化,我们已经了解过,分词器,模型和数据集是大模型的基本组件,我们对其进行初始化。" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9818e376-7f1e-4347-b8c3-c8e30f7d587f", + "metadata": {}, + "outputs": [], + "source": [ + "def init_model(lm_config):\n", + " tokenizer = AutoTokenizer.from_pretrained('../model/minimind_tokenizer')\n", + " model = MiniMindLM(lm_config).to(args.device)\n", + " print(f'LLM总参数量:{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f} 百万')\n", + " return model, tokenizer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "24d15001-b670-4333-9fbe-b224ab1993c5", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LLM总参数量:8.915 百万\n", + "cuda:0 6400 \n" + ] + } + ], + "source": [ + "lm_config = LMConfig(dim=args.dim, n_layers=args.n_layers, max_seq_len=args.max_seq_len, use_moe=args.use_moe)\n", + "model, tokenizer = init_model(lm_config)\n", + "\n", + "train_ds = PretrainDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len)\n", + "\n", + "train_loader = DataLoader(\n", + " train_ds,\n", + " batch_size=args.batch_size,\n", + " pin_memory=True,\n", + " drop_last=False,\n", + " shuffle=False,\n", + " num_workers=args.num_workers,\n", + ")\n", + "\n", + "print(model.device, tokenizer.vocab_size, train_loader)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "60c8c970-f448-4ea0-b21b-8f929e03f6fa", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "([tensor([[ 1, 46, 46, ..., 0, 0, 0],\n", + " [ 1, 5349, 1619, ..., 0, 0, 0]]),\n", + " tensor([[ 46, 46, 47, ..., 0, 0, 0],\n", + " [5349, 1619, 2875, ..., 0, 0, 0]]),\n", + " tensor([[1, 1, 1, ..., 0, 0, 0],\n", + " [1, 1, 1, ..., 0, 0, 0]])],\n", + " 2,\n", + " 1)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "loader = iter(train_loader)\n", + "next(loader), len(train_ds), len(loader)" + ] + }, + { + "cell_type": "markdown", + "id": "0d91904d-7ddf-4105-a5f5-65b84315a73b", + "metadata": {}, + "source": [ + "我们发现,train loader 的每一个 iter 都包含一个长度为 3 的张量列表,这是因为 train_dataset 每一次取数据都会返回三个张量,分别为:\n", + "\n", + "- 样本 X: 包含 \\ 在内的输入 content\n", + "- 标签 Y: 包含 \\ 在内的输出 content\n", + "- 掩码 loss_mask: 指示需要计算损失的 token 位置\n", + "\n", + "由于我们的数据集只有两条数据,而 batch size 设置为 2,因此我们的 dataloader 只有一个 iter." + ] + }, + { + "cell_type": "markdown", + "id": "40764309-9c98-45d5-ada5-e31c1aaa02c5", + "metadata": {}, + "source": [ + "## 启动训练\n", + "\n", + "训练一个深度学习模型,还涉及到了优化器,损失函数和学习率调度。接下来,我们查看 MiniMind 训练部分的代码,并进行一轮简单的训练。" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "4f70b9cf-fcff-4242-bd96-ce34a5da55e4", + "metadata": {}, + "outputs": [], + "source": [ + "# 学习率调度方面 采用余弦退火学习率\n", + "def get_lr(current_step, total_steps, lr):\n", + " return lr / 10 + 0.5 * lr * (1 + math.cos(math.pi * current_step / total_steps))\n", + "\n", + "# 优化器方面 选择 AdamW 优化器 并在混精度场景下创建 scaler 进行梯度缩放避免数值下溢\n", + "scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16']))\n", + "optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)\n", + "\n", + "device_type = \"cuda\" if \"cuda\" in args.device else \"cpu\"\n", + "ctx = nullcontext() if device_type == \"cpu\" else torch.cuda.amp.autocast() # 在 cuda 上启动混精度训练,否则空白上下文" + ] + }, + { + "cell_type": "markdown", + "id": "c101ffb4-1a97-4e29-8612-caf40a8aa05d", + "metadata": {}, + "source": [ + "接下来,我们来看看 MiniMind 的训练函数" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "03043d8e-e90b-42a8-ba4d-de6187f6eb81", + "metadata": {}, + "outputs": [], + "source": [ + "def train_epoch(epoch):\n", + " loss_fct = nn.CrossEntropyLoss(reduction='none') # 损失函数 采用交叉熵损失\n", + " start_time = time.time()\n", + " for step, (X, Y, loss_mask) in enumerate(train_loader):\n", + " X = X.to(args.device)\n", + " Y = Y.to(args.device)\n", + " loss_mask = loss_mask.to(args.device)\n", + "\n", + " lr = get_lr(epoch * iter_per_epoch + step, args.epochs * iter_per_epoch, args.learning_rate)\n", + " for param_group in optimizer.param_groups:\n", + " param_group['lr'] = lr\n", + "\n", + " with ctx:\n", + " res = model(X) # 前向推理\n", + " loss = loss_fct(\n", + " res.logits.view(-1, res.logits.size(-1)),\n", + " Y.view(-1)\n", + " ).view(Y.size()) # 取生成的最后一个 token 的 logits 计算损失\n", + " loss = (loss * loss_mask).sum() / loss_mask.sum()\n", + " loss += res.aux_loss # 若为混合专家 则将 MOE 辅助损失纳入考虑\n", + " loss = loss / args.accumulation_steps # 梯度累积\n", + "\n", + " scaler.scale(loss).backward() # 梯度缩放\n", + "\n", + " if (step + 1) % args.accumulation_steps == 0:\n", + " scaler.unscale_(optimizer)\n", + " torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) # 梯度剪裁\n", + "\n", + " scaler.step(optimizer)\n", + " scaler.update()\n", + "\n", + " optimizer.zero_grad(set_to_none=True) # 单步更新\n", + "\n", + " if step % args.log_interval == 0:\n", + " spend_time = time.time() - start_time\n", + " print(\n", + " 'Epoch:[{}/{}]({}/{}) loss:{:.3f} lr:{:.12f} epoch_Time:{}min:'.format(\n", + " epoch + 1,\n", + " args.epochs,\n", + " step,\n", + " iter_per_epoch,\n", + " loss.item() * args.accumulation_steps,\n", + " optimizer.param_groups[-1]['lr'],\n", + " spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60))\n", + "\n", + " # 到达指定保存步数时,save as PyTorch\n", + " # if (step + 1) % args.save_interval == 0 and (not ddp or dist.get_rank() == 0):\n", + " # model.eval()\n", + " # moe_path = '_moe' if lm_config.use_moe else ''\n", + " # ckp = f'{args.save_dir}/pretrain_{lm_config.dim}{moe_path}.pth'\n", + "\n", + " # if isinstance(model, torch.nn.parallel.DistributedDataParallel):\n", + " # state_dict = model.module.state_dict()\n", + " # else:\n", + " # state_dict = model.state_dict()\n", + "\n", + " # torch.save(state_dict, ckp)\n", + " # model.train()" + ] + }, + { + "cell_type": "markdown", + "id": "7792bf9c-95a1-4c0d-bb53-fac2a1132ee0", + "metadata": {}, + "source": [ + "准备完毕,我们进行一轮长度 1 个 iter 的训练看看怎么个事。" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "17f51549-2009-4322-86c8-7aa783169e0b", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch:[1/1](0/1) loss:8.889 lr:0.000550000000 epoch_Time:0.0min:\n" + ] + } + ], + "source": [ + "iter_per_epoch = len(train_loader)\n", + "for epoch in range(args.epochs):\n", + " train_epoch(epoch)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebook/4_sft.ipynb b/notebook/4_sft.ipynb new file mode 100644 index 0000000..8313ca6 --- /dev/null +++ b/notebook/4_sft.ipynb @@ -0,0 +1,382 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "70560a4d-ff57-4699-afb2-52f6a145b97e", + "metadata": {}, + "source": [ + "# 4-SFT\n", + "\n", + "在预训练阶段后,我们应该能够获得一个下一词预测模型,此时的模型已经掌握了大量的知识。不过,仅仅具备下一词预测能力是不够的,我们希望大模型能够获得问答能力,这一能力便是在有监督微调(Supervised Fine Tuning,SFT)阶段获得的。\n", + "\n", + "在这个笔记本中,我们仅对 SFT 的训练流程进行展示和学习,因此只给出必要的代码片段,如 wandb 和 ddp 不会在此笔记本中涉及。\n", + "\n", + "此笔记本的完整实现见主仓库 `/minimind/train_full_sft.py`" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "2508c8c7-eb44-4ac0-918c-41479f41d1ac", + "metadata": {}, + "outputs": [], + "source": [ + "# 导入依赖\n", + "import os\n", + "import platform\n", + "import argparse\n", + "import time\n", + "import math\n", + "import warnings\n", + "\n", + "import pandas as pd\n", + "import torch\n", + "import torch.nn.functional as F\n", + "import torch.distributed as dist\n", + "from contextlib import nullcontext\n", + "\n", + "from torch import optim, nn\n", + "from torch.nn.parallel import DistributedDataParallel\n", + "from torch.utils.data import DataLoader, DistributedSampler\n", + "from transformers import AutoTokenizer, AutoModelForCausalLM\n", + "from demo.model import MiniMindLM\n", + "from demo.LMConfig import LMConfig\n", + "from demo.dataset import SFTDataset" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "4a79e140-23c7-4498-9a4d-ef7459692996", + "metadata": {}, + "outputs": [], + "source": [ + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "markdown", + "id": "dbe1d40e-e074-4856-8c67-da8019f250ab", + "metadata": {}, + "source": [ + "## 可选参数设置\n", + "\n", + "首先,查看训练的可选参数,这些参数在实际使用时通过命令行导入,为了保持笔记本的易用性,选择用 class 进行包装" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "98cbd6cf-081f-4c31-8dce-52566004801a", + "metadata": {}, + "outputs": [], + "source": [ + "class args:\n", + " # out_dir: str = \"out\" # pytorch 格式权重文件保存位置 我们只展示训练过程 所以不使用\n", + " epochs: int = 1 # 训练轮数\n", + " batch_size: int = 2 # pretrain 数据集仅两个样本,设置 batch 为 2\n", + " learning_rate: float = 5e-4 # 学习率\n", + " device: str = 'cuda' if torch.cuda.is_available() else 'cpu'\n", + " dtype: str = 'bfloat16' # 16 bit 浮点数:8 bit 指数 + 7 bit 尾数\n", + " # use_wandb: bool = False # 是否使用 wandb 我们不使用\n", + " wandb_project: str = 'MiniMind-Notebook'\n", + " num_workers: int = 1 # 工作进程数\n", + " # ddp:bool = False # 单机多卡\n", + " accumulation_steps: int = 1 # 梯度累积步数\n", + " grad_clip: float = 1.0 # 梯度剪裁\n", + " warmup_iters: int = 0 # 学习率热启动\n", + " log_interval: int = 1 # 每一步打印日志 仅用于观察\n", + " # save_interval: int = 100 # checkpoint 保存点 我们不使用\n", + " local_rank: int = 1 # device 设备号\n", + " dim: int = 512 # 词嵌入维度 模型超参数\n", + " n_layers: int = 2 # MiniMind Block 数量 模型超参数\n", + " max_seq_len: int = 512 # 序列长度阈值\n", + " use_moe: bool = False # 是否启用混合专家\n", + " data_path: str = './demo/sft_data.jsonl' # 数据集路径" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "642f5107-aa9b-49cc-9759-e8731326edc3", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'cuda'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "args.device" + ] + }, + { + "cell_type": "markdown", + "id": "b70fadb6-f78f-45a0-ad3e-d41d7253c72b", + "metadata": {}, + "source": [ + "## 初始化训练\n", + "\n", + "接下来,我们对一些重要模块进行初始化,我们已经了解过,分词器,模型和数据集是大模型的基本组件,我们对其进行初始化。\n", + "\n", + "> 注意 与预训练阶段不同的是 在 sft 阶段 我们实际上是在上一阶段训练获得的模型的基础上修改数据集进行接续训练 因此需要载入上一阶段的模型权重 出于展示的目的 载入权重的代码在此笔记本中只作展示 并不执行" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "c64a0e37-a9d0-4ce6-b5ee-7b2728de879b", + "metadata": {}, + "outputs": [], + "source": [ + "def init_model(lm_config):\n", + " tokenizer = AutoTokenizer.from_pretrained('../model/minimind_tokenizer')\n", + " model = MiniMindLM(lm_config).to(args.device)\n", + " moe_path = '_moe' if lm_config.use_moe else ''\n", + " # ckp = f'./out/pretrain_{lm_config.dim}{moe_path}.pth' # 指示上一阶段训练保存的模型文件位置\n", + " # state_dict = torch.load(ckp, map_location=args.device) # 载入模型状态字典\n", + " # model.laod_state_dict(state_dict, strict=False) # 装入模型\n", + " print(f'LLM总参数量:{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f} 百万')\n", + " model = model.to(args.device)\n", + " return model, tokenizer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "2eec33f3-2ac2-4c75-b58e-844d89d8add9", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LLM总参数量:8.915 百万\n", + "cuda:0 6400 \n" + ] + } + ], + "source": [ + "lm_config = LMConfig(dim=args.dim, n_layers=args.n_layers, max_seq_len=args.max_seq_len, use_moe=args.use_moe)\n", + "model, tokenizer = init_model(lm_config)\n", + "\n", + "train_ds = SFTDataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len)\n", + "\n", + "train_loader = DataLoader(\n", + " train_ds,\n", + " batch_size=args.batch_size,\n", + " pin_memory=True,\n", + " drop_last=False,\n", + " shuffle=False,\n", + " num_workers=args.num_workers,\n", + ")\n", + "\n", + "print(model.device, tokenizer.vocab_size, train_loader)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "b02644e0-e61d-4df0-9203-fa841cd83635", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "([tensor([[ 1, 85, 736, ..., 0, 0, 0],\n", + " [ 1, 85, 736, ..., 0, 0, 0]]),\n", + " tensor([[ 85, 736, 201, ..., 0, 0, 0],\n", + " [ 85, 736, 201, ..., 0, 0, 0]]),\n", + " tensor([[0, 0, 0, ..., 0, 0, 0],\n", + " [0, 0, 0, ..., 0, 0, 0]])],\n", + " 2,\n", + " 1)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "loader = iter(train_loader)\n", + "next(loader), len(train_ds), len(loader)" + ] + }, + { + "cell_type": "markdown", + "id": "13ac8a47-d26a-49da-82fb-468ef5ac91a4", + "metadata": {}, + "source": [ + "我们发现,train loader 的每一个 iter 都包含一个长度为 3 的张量列表,这是因为 train_dataset 每一次取数据都会返回三个张量,分别为:\n", + "\n", + "- 样本 X: 包含 \\ 在内的输入 conversation\n", + "- 标签 Y: 包含 \\ 在内的输出 conversation\n", + "- 掩码 loss_mask: 指示需要计算损失的 token 位置\n", + "\n", + "由于我们的数据集只有两条数据,而 batch size 设置为 2,因此我们的 dataloader 只有一个 iter." + ] + }, + { + "cell_type": "markdown", + "id": "ee23f937-f333-4c02-a3b4-a768818c0e2b", + "metadata": {}, + "source": [ + "## 启动训练\n", + "\n", + "训练一个深度学习模型,还涉及到了优化器,损失函数和学习率调度。接下来,我们查看 MiniMind 训练部分的代码,并进行一轮简单的训练。\n", + "\n", + "> 不难发现 pretrain 阶段和 sft 阶段的训练主体差不多 因为这两个阶段的差异体现在数据集格式 而数据集在经过 chat template 格式化后差异小了很多" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "5c32a59a-be0b-4426-b47e-c522322bef69", + "metadata": {}, + "outputs": [], + "source": [ + "# 学习率调度方面 采用余弦退火学习率\n", + "def get_lr(current_step, total_steps, lr):\n", + " return lr / 10 + 0.5 * lr * (1 + math.cos(math.pi * current_step / total_steps))\n", + "\n", + "# 优化器方面 选择 AdamW 优化器 并在混精度场景下创建 scaler 进行梯度缩放避免数值下溢\n", + "scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16']))\n", + "optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)\n", + "\n", + "device_type = \"cuda\" if \"cuda\" in args.device else \"cpu\"\n", + "ctx = nullcontext() if device_type == \"cpu\" else torch.cuda.amp.autocast() # 在 cuda 上启动混精度训练,否则空白上下文" + ] + }, + { + "cell_type": "markdown", + "id": "13963ec0-d22e-4120-b0f2-8f5e5abfcda1", + "metadata": {}, + "source": [ + "接下来,我们来看看 MiniMind 的训练函数" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "74499592-2065-4fff-97d1-762cd08b77b6", + "metadata": {}, + "outputs": [], + "source": [ + "def train_epoch(epoch):\n", + " loss_fct = nn.CrossEntropyLoss(reduction='none') # 损失函数 采用交叉熵损失\n", + " start_time = time.time()\n", + " for step, (X, Y, loss_mask) in enumerate(train_loader):\n", + " X = X.to(args.device)\n", + " Y = Y.to(args.device)\n", + " loss_mask = loss_mask.to(args.device)\n", + "\n", + " lr = get_lr(epoch * iter_per_epoch + step, args.epochs * iter_per_epoch, args.learning_rate)\n", + " for param_group in optimizer.param_groups:\n", + " param_group['lr'] = lr\n", + "\n", + " with ctx:\n", + " res = model(X) # 前向推理\n", + " loss = loss_fct(\n", + " res.logits.view(-1, res.logits.size(-1)),\n", + " Y.view(-1)\n", + " ).view(Y.size()) # 取生成的最后一个 token 的 logits 计算损失\n", + " loss = (loss * loss_mask).sum() / loss_mask.sum()\n", + " loss += res.aux_loss # 若为混合专家 则将 MOE 辅助损失纳入考虑\n", + " loss = loss / args.accumulation_steps # 梯度累积\n", + "\n", + " scaler.scale(loss).backward() # 梯度缩放\n", + "\n", + " if (step + 1) % args.accumulation_steps == 0:\n", + " scaler.unscale_(optimizer)\n", + " torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) # 梯度剪裁\n", + "\n", + " scaler.step(optimizer)\n", + " scaler.update()\n", + "\n", + " optimizer.zero_grad(set_to_none=True) # 单步更新\n", + "\n", + " if step % args.log_interval == 0:\n", + " spend_time = time.time() - start_time\n", + " print(\n", + " 'Epoch:[{}/{}]({}/{}) loss:{:.3f} lr:{:.12f} epoch_Time:{}min:'.format(\n", + " epoch + 1,\n", + " args.epochs,\n", + " step,\n", + " iter_per_epoch,\n", + " loss.item() * args.accumulation_steps,\n", + " optimizer.param_groups[-1]['lr'],\n", + " spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60))\n", + "\n", + " # 到达指定保存步数时,save as PyTorch\n", + " # if (step + 1) % args.save_interval == 0 and (not ddp or dist.get_rank() == 0):\n", + " # model.eval()\n", + " # moe_path = '_moe' if lm_config.use_moe else ''\n", + " # ckp = f'{args.save_dir}/pretrain_{lm_config.dim}{moe_path}.pth'\n", + "\n", + " # if isinstance(model, torch.nn.parallel.DistributedDataParallel):\n", + " # state_dict = model.module.state_dict()\n", + " # else:\n", + " # state_dict = model.state_dict()\n", + "\n", + " # torch.save(state_dict, ckp)\n", + " # model.train()" + ] + }, + { + "cell_type": "markdown", + "id": "546f28c2-418e-453c-88aa-695468f9216c", + "metadata": {}, + "source": [ + "准备完毕,我们进行一轮长度 1 个 iter 的训练看看怎么个事。" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "341200c9-0d3a-4ed1-9dd2-d2fc35d02564", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch:[1/1](0/1) loss:8.865 lr:0.000550000000 epoch_Time:0.0min:\n" + ] + } + ], + "source": [ + "iter_per_epoch = len(train_loader)\n", + "for epoch in range(args.epochs):\n", + " train_epoch(epoch)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebook/5_dpo.ipynb b/notebook/5_dpo.ipynb new file mode 100644 index 0000000..9b72797 --- /dev/null +++ b/notebook/5_dpo.ipynb @@ -0,0 +1,456 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "f53f31fb-4795-49cf-9a68-a13f8f7e5cae", + "metadata": {}, + "source": [ + "# 5-DPO\n", + "\n", + "直接偏好优化(Direct Preference Optimization,DPO)是后训练阶段中,使用正反样例激励大模型产生符合人类偏好的回答的策略,为人类反馈强化学习(Reinforcement Learning from Human Feedback, RLHF)提供了一个高效简化的替代方案。通过这一阶段的训练,大模型将会学会依照人类的喜好生成回复。\n", + "\n", + "在这个笔记本中,我们仅对 DPO 的训练流程进行展示和学习,因此只给出必要的代码片段,如 wandb 和 ddp 不会在此笔记本中涉及。\n", + "\n", + "此笔记本的完整实现见主仓库 `/minimind/train_dpo.py`" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "id": "3e09f234-5b8b-4bfb-8d42-bb766a457acc", + "metadata": {}, + "outputs": [], + "source": [ + "# 导入依赖\n", + "import os\n", + "import platform\n", + "import argparse\n", + "import time\n", + "import math\n", + "import warnings\n", + "\n", + "import pandas as pd\n", + "import torch\n", + "import torch.nn.functional as F\n", + "import torch.distributed as dist\n", + "from contextlib import nullcontext\n", + "\n", + "from torch import optim, nn\n", + "from torch.nn.parallel import DistributedDataParallel\n", + "from torch.utils.data import DataLoader, DistributedSampler\n", + "from transformers import AutoTokenizer, AutoModelForCausalLM\n", + "from demo.model import MiniMindLM\n", + "from demo.LMConfig import LMConfig\n", + "from demo.dataset import DPODataset" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "eb946301-eb7e-45c6-9e1c-16cc0ee4db82", + "metadata": {}, + "outputs": [], + "source": [ + "warnings.filterwarnings('ignore')" + ] + }, + { + "cell_type": "markdown", + "id": "d70c65d3-14f7-4569-bebd-cddb48f1851f", + "metadata": {}, + "source": [ + "## 可选参数设置\n", + "\n", + "首先,查看训练的可选参数,这些参数在实际使用时通过命令行导入,为了保持笔记本的易用性,选择用 class 进行包装" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "b78b11a1-9189-4eaf-a094-07c0497c4d38", + "metadata": {}, + "outputs": [], + "source": [ + "class args:\n", + " # out_dir: str = \"out\" # pytorch 格式权重文件保存位置 我们只展示训练过程 所以不使用\n", + " epochs: int = 1 # 训练轮数\n", + " batch_size: int = 2 # pretrain 数据集仅两个样本,设置 batch 为 2\n", + " # sft阶段学习率为 「5e-6」->「5e-7」长度512,建议离线正负样本「概率」偏好对齐阶段lr <=「1e-8」长度3000,否则很容易遗忘训坏\n", + " learning_rate: float = 5e-4 # 学习率\n", + " device: str = 'cuda' if torch.cuda.is_available() else 'cpu'\n", + " dtype: str = 'bfloat16' # 16 bit 浮点数:8 bit 指数 + 7 bit 尾数\n", + " # use_wandb: bool = False # 是否使用 wandb 我们不使用\n", + " wandb_project: str = 'MiniMind-Notebook'\n", + " num_workers: int = 1 # 工作进程数\n", + " # ddp:bool = False # 单机多卡\n", + " accumulation_steps: int = 1 # 梯度累积步数\n", + " grad_clip: float = 1.0 # 梯度剪裁\n", + " warmup_iters: int = 0 # 学习率热启动\n", + " log_interval: int = 1 # 每一步打印日志 仅用于观察\n", + " # save_interval: int = 100 # checkpoint 保存点 我们不使用\n", + " local_rank: int = 1 # device 设备号\n", + " dim: int = 512 # 词嵌入维度 模型超参数\n", + " n_layers: int = 1 # MiniMind Block 数量 模型超参数 | 由于 dpo 要加载两个模型 我们出于演示目的设定 n_layers = 1\n", + " max_seq_len: int = 512 # 序列长度阈值\n", + " use_moe: bool = False # 是否启用混合专家\n", + " data_path: str = './demo/dpo_data.jsonl' # 数据集路径" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "0a72c8aa-ca60-4b0d-aabb-2c9d16b48175", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'cuda'" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "args.device" + ] + }, + { + "cell_type": "markdown", + "id": "f8fb31ff-524b-4788-a25f-699edb887704", + "metadata": {}, + "source": [ + "## 初始化训练\n", + "\n", + "接下来,我们对一些重要模块进行初始化,我们已经了解过,分词器,模型和数据集是大模型的基本组件,我们对其进行初始化。\n", + "\n", + "> 在这一阶段 我们调整的是大模型的问答偏好 因此与 sft 阶段同理 我们需要载入在 sft 阶段微调好的问答模型" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "5d5971fc-4794-4903-b418-76f266bf19d9", + "metadata": {}, + "outputs": [], + "source": [ + "def init_model(lm_config):\n", + " \"\"\"注意 在此处我们注释了加载模型部分代码 但应当认识到 actor 和 ref 两个模型都具备 sft 阶段获得的权重\"\"\"\n", + " tokenizer = AutoTokenizer.from_pretrained('../model/minimind_tokenizer')\n", + " model = MiniMindLM(lm_config)\n", + " moe_path = '_moe' if lm_config.use_moe else ''\n", + " # ckp = f'./out/full_sft_{lm_config.dim}{moe_path}.pth' # 指示上一阶段训练保存的模型文件位置\n", + " # state_dict = torch.load(ckp, map_location=args.device) # 载入模型状态字典\n", + " # model.load_state_dict(state_dict, strict=False) # 装入模型\n", + " # 初始化参考模型\n", + " ref_model = MiniMindLM(lm_config)\n", + " # ref_model.load_state_dict(state_dict, strict=False)\n", + " ref_model.eval()\n", + " ref_model.requires_grad_(False)\n", + "\n", + " print(f'LLM总参数量:{sum(p.numel() for p in model.parameters() if p.requires_grad) / 1e6:.3f} 百万')\n", + " model = model.to(args.device)\n", + " ref_model = ref_model.to(args.device)\n", + "\n", + " return model, ref_model, tokenizer" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "fbd7bd15-a060-4aba-8c5d-277705ec6b52", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "LLM总参数量:6.096 百万\n", + "cuda:0 6400 \n" + ] + } + ], + "source": [ + "lm_config = LMConfig(dim=args.dim, n_layers=args.n_layers, max_seq_len=args.max_seq_len, use_moe=args.use_moe)\n", + "model, ref_model ,tokenizer = init_model(lm_config)\n", + "\n", + "train_ds = DPODataset(args.data_path, tokenizer, max_length=lm_config.max_seq_len)\n", + "\n", + "train_loader = DataLoader(\n", + " train_ds,\n", + " batch_size=args.batch_size,\n", + " pin_memory=True,\n", + " drop_last=False,\n", + " shuffle=False,\n", + " num_workers=args.num_workers,\n", + ")\n", + "\n", + "print(model.device, tokenizer.vocab_size, train_loader)" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "d807a2af-41e9-4115-b470-a05f6a04613c", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "({'x_chosen': tensor([[ 1, 85, 736, ..., 0, 0, 0],\n", + " [ 1, 85, 736, ..., 0, 0, 0]]),\n", + " 'y_chosen': tensor([[ 85, 736, 201, ..., 0, 0, 0],\n", + " [ 85, 736, 201, ..., 0, 0, 0]]),\n", + " 'mask_chosen': tensor([[0, 0, 0, ..., 0, 0, 0],\n", + " [0, 0, 0, ..., 0, 0, 0]]),\n", + " 'x_rejected': tensor([[ 1, 85, 736, ..., 0, 0, 0],\n", + " [ 1, 85, 736, ..., 0, 0, 0]]),\n", + " 'y_rejected': tensor([[ 85, 736, 201, ..., 0, 0, 0],\n", + " [ 85, 736, 201, ..., 0, 0, 0]]),\n", + " 'mask_rejected': tensor([[0, 0, 0, ..., 0, 0, 0],\n", + " [0, 0, 0, ..., 0, 0, 0]])},\n", + " 2,\n", + " 1)" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "loader = iter(train_loader)\n", + "next(loader), len(train_ds), len(loader)" + ] + }, + { + "cell_type": "markdown", + "id": "665ad506-ab12-4186-ad63-ada143a33d52", + "metadata": {}, + "source": [ + "我们发现,train loader 的每一个 iter 都包含一个拥有六个键值对的字典,这是因为 train_dataset 每一次取数据都会返回:\n", + "\n", + "- chosen 样本 X: 包含 \\ 在内的输入 content\n", + "- chosen 标签 Y: 包含 \\ 在内的输出 content\n", + "- chosen 掩码 loss_mask: 指示需要计算损失的 token 位置\n", + "- rejected 样本 X: 包含 \\ 在内的输入 content\n", + "- rejected 标签 Y: 包含 \\ 在内的输出 content\n", + "- rejected 掩码 loss_mask: 指示需要计算损失的 token 位置\n", + "\n", + "由于我们的数据集只有两条数据,而 batch size 设置为 2,因此我们的 dataloader 只有一个 iter." + ] + }, + { + "cell_type": "markdown", + "id": "32462eb9-7008-4389-a54b-47ef1c1c4732", + "metadata": {}, + "source": [ + "# 启动训练\n", + "\n", + "训练一个深度学习模型,还涉及到了优化器,损失函数和学习率调度。接下来,我们查看 MiniMind 训练部分的代码,并进行一轮简单的训练。\n", + "\n", + "> DPO 阶段涉及 DPO 损失函数涉及 因此与前两个阶段相比内容略有增加 不过整体流程与逻辑类似" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "1752f117-33c0-4810-886f-fbd1b4d44c28", + "metadata": {}, + "outputs": [], + "source": [ + "# 学习率调度方面 采用余弦退火学习率\n", + "def get_lr(current_step, total_steps, lr):\n", + " return lr / 10 + 0.5 * lr * (1 + math.cos(math.pi * current_step / total_steps))\n", + "\n", + "# 优化器方面 选择 AdamW 优化器 并在混精度场景下创建 scaler 进行梯度缩放避免数值下溢\n", + "scaler = torch.cuda.amp.GradScaler(enabled=(args.dtype in ['float16', 'bfloat16']))\n", + "optimizer = optim.AdamW(model.parameters(), lr=args.learning_rate)\n", + "\n", + "device_type = \"cuda\" if \"cuda\" in args.device else \"cpu\"\n", + "ctx = nullcontext() if device_type == \"cpu\" else torch.cuda.amp.autocast() # 在 cuda 上启动混精度训练,否则空白上下文" + ] + }, + { + "cell_type": "markdown", + "id": "5f2c3ac1-92bf-4eae-8d22-7fac4cfa69df", + "metadata": {}, + "source": [ + "DPO 的原理是增加偏好样本的对数概率与减小非偏好样本响应的对数概率。\n", + "\n", + "该阶段引入 DPO 损失函数,通过计算选择样本和拒绝样本的对数比率,然后基于这些比率计算 DPO 损失,适用于偏好学习任务。" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "2c07f54a-496e-4b1d-9394-dd2c266ab5d3", + "metadata": {}, + "outputs": [], + "source": [ + "def logits_to_probs(logits, labels):\n", + " # logits shape: (batch_size, seq_len, vocab_size)\n", + " # labels shape: (batch_size, seq_len)\n", + " # probs shape: (batch_size, seq_len)\n", + " log_probs = F.log_softmax(logits, dim=2)\n", + " probs = torch.gather(log_probs, dim=2, index=labels.unsqueeze(2)).squeeze(-1)\n", + " return probs\n", + "\n", + "\n", + "def dpo_loss(ref_probs, probs, beta):\n", + " # ref_probs 和 probs 都是 shape: (batch_size, seq_len)\n", + " # 计算每个样本的平均概率\n", + " ref_probs = ref_probs.mean(dim=1)\n", + " probs = probs.mean(dim=1)\n", + "\n", + " # 将 chosen 和 rejected 数据分开\n", + " batch_size = ref_probs.shape[0]\n", + " chosen_ref_probs = ref_probs[:batch_size // 2]\n", + " reject_ref_probs = ref_probs[batch_size // 2:]\n", + " chosen_probs = probs[:batch_size // 2]\n", + " reject_probs = probs[batch_size // 2:]\n", + "\n", + " # 计算对数比率,比较偏好差异\n", + " pi_logratios = chosen_probs - reject_probs\n", + " ref_logratios = chosen_ref_probs - reject_ref_probs\n", + " logits = pi_logratios - ref_logratios\n", + " loss = -F.logsigmoid(beta * logits)\n", + " return loss.mean()" + ] + }, + { + "cell_type": "markdown", + "id": "e743562c-6e34-488d-bd84-f62eaee0ae20", + "metadata": {}, + "source": [ + "接下来,我们来看看 MiniMind 的训练函数" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "id": "493b7368-d82f-48a6-b5bd-7737b7a23bee", + "metadata": {}, + "outputs": [], + "source": [ + "def train_epoch(epoch):\n", + " start_time = time.time()\n", + " for step, batch in enumerate(train_loader):\n", + " # 提取数据\n", + " x_chosen = batch['x_chosen'].to(args.device)\n", + " x_rejected = batch['x_rejected'].to(args.device)\n", + " y_chosen = batch['y_chosen'].to(args.device)\n", + " y_rejected = batch['y_rejected'].to(args.device)\n", + " mask_chosen = batch['mask_chosen'].to(args.device)\n", + " mask_rejected = batch['mask_rejected'].to(args.device)\n", + " # 正反例拼接\n", + " x = torch.cat([x_chosen, x_rejected], dim=0)\n", + " y = torch.cat([y_chosen, y_rejected], dim=0)\n", + " mask = torch.cat([mask_chosen, mask_rejected], dim=0)\n", + "\n", + " lr = get_lr(epoch * iter_per_epoch + step, args.epochs * iter_per_epoch, args.learning_rate)\n", + " for param_group in optimizer.param_groups:\n", + " param_group['lr'] = lr\n", + "\n", + " with ctx:\n", + " with torch.no_grad(): # 计算 ref 模型输出\n", + " ref_outputs = ref_model(x)\n", + " ref_logits = ref_outputs.logits\n", + " ref_probs = logits_to_probs(ref_logits, y)\n", + " ref_probs = ref_probs * mask # 得到 ref 概率\n", + " outputs = model(x) # 计算 actor 模型输出\n", + " logits = outputs.logits\n", + " probs = logits_to_probs(logits, y)\n", + " probs = probs * mask # 得到 actor 概率\n", + " loss = dpo_loss(ref_probs, probs, beta=0.1) # dpo 损失\n", + " loss = loss / args.accumulation_steps\n", + "\n", + " scaler.scale(loss).backward() # 梯度缩放\n", + "\n", + " if (step + 1) % args.accumulation_steps == 0:\n", + " scaler.unscale_(optimizer)\n", + " torch.nn.utils.clip_grad_norm_(model.parameters(), args.grad_clip) # 梯度剪裁\n", + " scaler.step(optimizer)\n", + " scaler.update()\n", + " optimizer.zero_grad(set_to_none=True)\n", + "\n", + " if step % args.log_interval == 0:\n", + " spend_time = time.time() - start_time\n", + " print(\n", + " 'Epoch:[{}/{}]({}/{}) loss:{:.3f} lr:{:.12f} epoch_Time:{}min:'.format(\n", + " epoch + 1,\n", + " args.epochs,\n", + " step,\n", + " iter_per_epoch,\n", + " loss.item(),\n", + " optimizer.param_groups[-1]['lr'],\n", + " spend_time / (step + 1) * iter_per_epoch // 60 - spend_time // 60))\n", + "\n", + " # 到达指定保存步数时,save as PyTorch\n", + " # if (step + 1) % args.save_interval == 0 and (not ddp or dist.get_rank() == 0):\n", + " # model.eval()\n", + " # moe_path = '_moe' if lm_config.use_moe else ''\n", + " # ckp = f'{args.save_dir}/rlhf_{lm_config.dim}{moe_path}.pth'\n", + "\n", + " # if isinstance(model, torch.nn.parallel.DistributedDataParallel):\n", + " # state_dict = model.module.state_dict()\n", + " # else:\n", + " # state_dict = model.state_dict()\n", + "\n", + " # torch.save(state_dict, ckp)\n", + " # model.train()" + ] + }, + { + "cell_type": "markdown", + "id": "49386dc7-2919-428a-a073-f37d576afbcf", + "metadata": {}, + "source": [ + "准备完毕,我们进行一轮长度 1 个 iter 的训练看看怎么个事。" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "id": "0ac86572-ee04-43b5-b5fa-b1c65cbaebec", + "metadata": {}, + "outputs": [ + { + "name": "stdout", + "output_type": "stream", + "text": [ + "Epoch:[1/1](0/1) loss:0.693 lr:0.000550000000 epoch_Time:0.0min:\n" + ] + } + ], + "source": [ + "iter_per_epoch = len(train_loader)\n", + "for epoch in range(args.epochs):\n", + " train_epoch(epoch)" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.14" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/notebook/demo/LMConfig.py b/notebook/demo/LMConfig.py new file mode 100644 index 0000000..323c110 --- /dev/null +++ b/notebook/demo/LMConfig.py @@ -0,0 +1,61 @@ +from transformers import PretrainedConfig +from typing import List + + +class LMConfig(PretrainedConfig): + model_type = "minimind" + + def __init__( + self, + dim: int = 512, + n_layers: int = 1, # n_layers set to 1 to enable local practice + n_heads: int = 8, + n_kv_heads: int = 2, + vocab_size: int = 6400, + hidden_dim: int = None, + multiple_of: int = 64, + norm_eps: float = 1e-5, + max_seq_len: int = 8192, + rope_theta: int = 1e6, + dropout: float = 0.0, + flash_attn: bool = True, + #################################################### + # Here are the specific configurations of MOE + # When use_moe is false, the following is invalid + #################################################### + use_moe: bool = False, + #################################################### + num_experts_per_tok: int = 2, + n_routed_experts: int = 4, + n_shared_experts: bool = True, + scoring_func: str = 'softmax', + aux_loss_alpha: float = 0.1, + seq_aux: bool = True, + norm_topk_prob: bool = True, + **kwargs, + ): + self.dim = dim + self.n_layers = n_layers + self.n_heads = n_heads + self.n_kv_heads = n_kv_heads + self.vocab_size = vocab_size + self.hidden_dim = hidden_dim + self.multiple_of = multiple_of + self.norm_eps = norm_eps + self.max_seq_len = max_seq_len + self.rope_theta = rope_theta + self.dropout = dropout + self.flash_attn = flash_attn + #################################################### + # Here are the specific configurations of MOE + # When use_moe is false, the following is invalid + #################################################### + self.use_moe = use_moe + self.num_experts_per_tok = num_experts_per_tok # 每个token选择的专家数量 + self.n_routed_experts = n_routed_experts # 总的专家数量 + self.n_shared_experts = n_shared_experts # 共享专家 + self.scoring_func = scoring_func # 评分函数,默认为'softmax' + self.aux_loss_alpha = aux_loss_alpha # 辅助损失的alpha参数 + self.seq_aux = seq_aux # 是否在序列级别上计算辅助损失 + self.norm_topk_prob = norm_topk_prob # 是否标准化top-k概率 + super().__init__(**kwargs) diff --git a/notebook/demo/dataset.py b/notebook/demo/dataset.py new file mode 100644 index 0000000..7750789 --- /dev/null +++ b/notebook/demo/dataset.py @@ -0,0 +1,200 @@ +import json +import random +import re + +import pandas as pd +import numpy as np +from torch.utils.data import Dataset, DataLoader +import torch +from sklearn.model_selection import train_test_split +import os +import ast + +os.environ["TOKENIZERS_PARALLELISM"] = "false" + + +class PretrainDataset(Dataset): + def __init__(self, data_path, tokenizer, max_length=512): + super().__init__() + self.tokenizer = tokenizer + self.max_length = max_length + self.samples = self.load_data(data_path) + + def load_data(self, path): + samples = [] + with open(path, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + data = json.loads(line.strip()) + samples.append(data) + return samples + + def __len__(self): + return len(self.samples) + + def __getitem__(self, index): + sample = self.samples[index] + + # 构建输入文本 + text = f"{self.tokenizer.bos_token}{str(sample['text'])}{self.tokenizer.eos_token}" + encoding = self.tokenizer( + text, + max_length=self.max_length, + padding='max_length', + truncation=True, + return_tensors='pt' + ) + input_ids = encoding.input_ids.squeeze() + loss_mask = (input_ids != self.tokenizer.pad_token_id) + + X = torch.tensor(input_ids[:-1], dtype=torch.long) + Y = torch.tensor(input_ids[1:], dtype=torch.long) + loss_mask = torch.tensor(loss_mask[1:], dtype=torch.long) + return X, Y, loss_mask + + +class SFTDataset(Dataset): + def __init__(self, jsonl_path, tokenizer, max_length=1024): + super().__init__() + self.tokenizer = tokenizer + self.max_length = max_length + self.samples = self.load_data(jsonl_path) + self.bos_id = tokenizer('assistant\n', add_special_tokens=False).input_ids + self.eos_id = tokenizer('\n', add_special_tokens=False).input_ids + + def __len__(self): + return len(self.samples) + + def load_data(self, path): + samples = [] + with open(path, 'r', encoding='utf-8') as f: + for line_num, line in enumerate(f, 1): + data = json.loads(line.strip()) + samples.append(data) + return samples + + def _create_chat_prompt(self, conversations): + """构建符合ChatML格式的对话""" + messages = [] + for i, turn in enumerate(conversations): + role = 'user' if i % 2 == 0 else 'assistant' + messages.append({"role": role, "content": turn['content']}) + return self.tokenizer.apply_chat_template( + messages, + tokenize=False, + add_generation_prompt=False + ) + + def _generate_loss_mask(self, input_ids): + loss_mask = [0] * len(input_ids) + i = 0 + while i < len(input_ids): + if input_ids[i:i + len(self.bos_id)] == self.bos_id: + start = i + len(self.bos_id) + end = start + while end < len(input_ids): + if input_ids[end:end + len(self.eos_id)] == self.eos_id: + break + end += 1 + for j in range(start + 1, min(end + len(self.eos_id) + 1, self.max_length)): + loss_mask[j] = 1 + i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids) + else: + i += 1 + return loss_mask + + def __getitem__(self, index): + sample = self.samples[index] + # 构建对话提示 + prompt = self._create_chat_prompt(sample['conversations']) + input_ids = self.tokenizer(prompt).input_ids[:self.max_length] + input_ids += [self.tokenizer.pad_token_id] * (self.max_length - len(input_ids)) + + # 生成动态损失掩码 + loss_mask = self._generate_loss_mask(input_ids) + + # 构建训练数据 + X = torch.tensor(input_ids[:-1], dtype=torch.long) + Y = torch.tensor(input_ids[1:], dtype=torch.long) + loss_mask = torch.tensor(loss_mask[1:], dtype=torch.long) # 对齐预测位置 + + return X, Y, loss_mask + + +class DPODataset(Dataset): + def __init__(self, file_path, tokenizer, max_length=4096): + super().__init__() + self.tokenizer = tokenizer + self.max_length = max_length + self.padding = tokenizer.pad_token_id if tokenizer.pad_token_id is not None else 0 + self.bos_id = tokenizer('assistant\n', add_special_tokens=False).input_ids + self.eos_id = tokenizer('\n', add_special_tokens=False).input_ids + with open(file_path, 'r', encoding='utf-8') as f: + self.data = [] + for line in f: + line = line.strip() + obj = json.loads(line) + self.data.append(obj) + + def __len__(self): + return len(self.data) + + def __getitem__(self, index): + item = self.data[index] + chosen = item['chosen'] # 是一个 list,里面包含若干 {role, content} + rejected = item['rejected'] # 同上 + chosen_prompt = self.tokenizer.apply_chat_template( + chosen, tokenize=False, add_generation_prompt=False + ) + + rejected_prompt = self.tokenizer.apply_chat_template( + rejected, tokenize=False, add_generation_prompt=False + ) + chosen_encoding = self.tokenizer( + chosen_prompt, truncation=True, max_length=self.max_length, padding='max_length' + ) + rejected_encoding = self.tokenizer( + rejected_prompt, truncation=True, max_length=self.max_length, padding='max_length' + ) + + chosen_input_ids = chosen_encoding['input_ids'] + chosen_loss_mask = self._generate_loss_mask(chosen_input_ids) + + rejected_input_ids = rejected_encoding['input_ids'] + rejected_loss_mask = self._generate_loss_mask(rejected_input_ids) + x_chosen = torch.tensor(chosen_input_ids[:-1], dtype=torch.long) + y_chosen = torch.tensor(chosen_input_ids[1:], dtype=torch.long) + mask_chosen = torch.tensor(chosen_loss_mask[1:], dtype=torch.long) + x_rejected = torch.tensor(rejected_input_ids[:-1], dtype=torch.long) + y_rejected = torch.tensor(rejected_input_ids[1:], dtype=torch.long) + mask_rejected = torch.tensor(rejected_loss_mask[1:], dtype=torch.long) + + return { + 'x_chosen': x_chosen, + 'y_chosen': y_chosen, + 'mask_chosen': mask_chosen, + 'x_rejected': x_rejected, + 'y_rejected': y_rejected, + 'mask_rejected': mask_rejected + } + + def _generate_loss_mask(self, input_ids): + loss_mask = [0] * len(input_ids) + i = 0 + while i < len(input_ids): + if input_ids[i:i + len(self.bos_id)] == self.bos_id: + start = i + len(self.bos_id) + end = start + while end < len(input_ids): + if input_ids[end:end + len(self.eos_id)] == self.eos_id: + break + end += 1 + for j in range(start + 1, min(end + len(self.eos_id) + 1, self.max_length)): + loss_mask[j] = 1 + i = end + len(self.eos_id) if end < len(input_ids) else len(input_ids) + else: + i += 1 + return loss_mask + + +if __name__ == "__main__": + pass diff --git a/notebook/demo/dpo_data.jsonl b/notebook/demo/dpo_data.jsonl new file mode 100644 index 0000000..c465539 --- /dev/null +++ b/notebook/demo/dpo_data.jsonl @@ -0,0 +1,2 @@ +{"chosen":[{"content":"你好吗?","role":"user"},{"content":"我很好,谢谢!你呢?","role":"assistant"},{"content":"今天过得怎么样?","role":"user"},{"content":"挺好的,去跑步了,心情不错。","role":"assistant"}],"rejected":[{"content":"你好吗?","role":"user"},{"content":"不好,我很累。","role":"assistant"},{"content":"你喜欢什么运动?","role":"user"},{"content":"我不喜欢运动,没兴趣。","role":"assistant"}]} +{"chosen":[{"content":"你喜欢旅行吗?","role":"user"},{"content":"我喜欢,尤其是去海边。","role":"assistant"},{"content":"你去过哪些地方?","role":"user"},{"content":"我去过欧洲、美国和日本,都是很棒的经历。","role":"assistant"}],"rejected":[{"content":"你喜欢旅行吗?","role":"user"},{"content":"不太喜欢,觉得太麻烦。","role":"assistant"},{"content":"你去过哪些地方?","role":"user"},{"content":"没去过太多地方,主要是待在家里。","role":"assistant"}]} \ No newline at end of file diff --git a/notebook/demo/gqa.png b/notebook/demo/gqa.png new file mode 100644 index 0000000000000000000000000000000000000000..e6916e00590c3e6d7d3428cbde5107d0eee7e80d GIT binary patch literal 65820 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 F{|Bs7SQY>P literal 0 HcmV?d00001 diff --git a/notebook/demo/model.py b/notebook/demo/model.py new file mode 100644 index 0000000..1dc6478 --- /dev/null +++ b/notebook/demo/model.py @@ -0,0 +1,376 @@ +import math +import struct +import inspect +import time + +from .LMConfig import LMConfig +from typing import Any, Optional, Tuple, List +import numpy as np +import torch +import torch.nn.functional as F +from torch import nn +from transformers import PreTrainedModel +from transformers.modeling_outputs import CausalLMOutputWithPast + + +class RMSNorm(torch.nn.Module): + def __init__(self, dim: int, eps: float): + super().__init__() + self.eps = eps + self.weight = nn.Parameter(torch.ones(dim)) + + def forward(self, x): + return self.weight * (x.float() * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)).type_as(x) + + +def precompute_pos_cis(dim: int, end: int = int(32 * 1024), theta: float = 1e6): + freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim)) + t = torch.arange(end, device=freqs.device) # type: ignore + freqs = torch.outer(t, freqs).float() # type: ignore + pos_cis = torch.polar(torch.ones_like(freqs), freqs) # complex64 + return pos_cis + + +def apply_rotary_emb(xq, xk, pos_cis): + def unite_shape(pos_cis, x): + ndim = x.ndim + assert 0 <= 1 < ndim + assert pos_cis.shape == (x.shape[1], x.shape[-1]) + shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)] + return pos_cis.view(*shape) + + xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2)) + xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2)) + pos_cis = unite_shape(pos_cis, xq_) + xq_out = torch.view_as_real(xq_ * pos_cis).flatten(3) + xk_out = torch.view_as_real(xk_ * pos_cis).flatten(3) + return xq_out.type_as(xq), xk_out.type_as(xk) + + +def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor: + """torch.repeat_interleave(x, dim=2, repeats=n_rep)""" + bs, slen, n_kv_heads, head_dim = x.shape + if n_rep == 1: + return x + return ( + x[:, :, :, None, :] + .expand(bs, slen, n_kv_heads, n_rep, head_dim) + .reshape(bs, slen, n_kv_heads * n_rep, head_dim) + ) + + +class Attention(nn.Module): + def __init__(self, args: LMConfig): + super().__init__() + self.n_kv_heads = args.n_heads if args.n_kv_heads is None else args.n_kv_heads + assert args.n_heads % self.n_kv_heads == 0 + self.n_local_heads = args.n_heads + self.n_local_kv_heads = self.n_kv_heads + self.n_rep = self.n_local_heads // self.n_local_kv_heads + self.head_dim = args.dim // args.n_heads + self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False) + self.wk = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False) + self.wv = nn.Linear(args.dim, self.n_kv_heads * self.head_dim, bias=False) + self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False) + self.attn_dropout = nn.Dropout(args.dropout) + self.resid_dropout = nn.Dropout(args.dropout) + self.dropout = args.dropout + self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention') and args.flash_attn + # print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0") + mask = torch.full((1, 1, args.max_seq_len, args.max_seq_len), float("-inf")) + mask = torch.triu(mask, diagonal=1) + self.register_buffer("mask", mask, persistent=False) + + def forward(self, + x: torch.Tensor, + pos_cis: torch.Tensor, + past_key_value: Optional[Tuple[torch.Tensor, torch.Tensor]] = None, + use_cache=False): + bsz, seq_len, _ = x.shape + xq, xk, xv = self.wq(x), self.wk(x), self.wv(x) + xq = xq.view(bsz, seq_len, self.n_local_heads, self.head_dim) + xk = xk.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim) + xv = xv.view(bsz, seq_len, self.n_local_kv_heads, self.head_dim) + + xq, xk = apply_rotary_emb(xq, xk, pos_cis) + # kv_cache实现 + if past_key_value is not None: + xk = torch.cat([past_key_value[0], xk], dim=1) + xv = torch.cat([past_key_value[1], xv], dim=1) + past_kv = (xk, xv) if use_cache else None + + xq, xk, xv = ( + xq.transpose(1, 2), + repeat_kv(xk, self.n_rep).transpose(1, 2), + repeat_kv(xv, self.n_rep).transpose(1, 2) + ) + if self.flash and seq_len != 1: + dropout_p = self.dropout if self.training else 0.0 + output = F.scaled_dot_product_attention( + xq, xk, xv, + attn_mask=None, + dropout_p=dropout_p, + is_causal=True + ) + else: + scores = (xq @ xk.transpose(-2, -1)) / math.sqrt(self.head_dim) + scores += self.mask[:, :, :seq_len, :seq_len] + scores = F.softmax(scores.float(), dim=-1).type_as(xq) + scores = self.attn_dropout(scores) + output = scores @ xv + + output = output.transpose(1, 2).reshape(bsz, seq_len, -1) + output = self.resid_dropout(self.wo(output)) + return output, past_kv + + +class FeedForward(nn.Module): + def __init__(self, config: LMConfig): + super().__init__() + if config.hidden_dim is None: + hidden_dim = 4 * config.dim + hidden_dim = int(2 * hidden_dim / 3) + config.hidden_dim = config.multiple_of * ((hidden_dim + config.multiple_of - 1) // config.multiple_of) + self.w1 = nn.Linear(config.dim, config.hidden_dim, bias=False) + self.w2 = nn.Linear(config.hidden_dim, config.dim, bias=False) + self.w3 = nn.Linear(config.dim, config.hidden_dim, bias=False) + self.dropout = nn.Dropout(config.dropout) + + def forward(self, x): + return self.dropout(self.w2(F.silu(self.w1(x)) * self.w3(x))) + + +class MoEGate(nn.Module): + def __init__(self, config: LMConfig): + super().__init__() + self.config = config + self.top_k = config.num_experts_per_tok + self.n_routed_experts = config.n_routed_experts + + self.scoring_func = config.scoring_func + self.alpha = config.aux_loss_alpha + self.seq_aux = config.seq_aux + + self.norm_topk_prob = config.norm_topk_prob + self.gating_dim = config.dim + self.weight = nn.Parameter(torch.empty((self.n_routed_experts, self.gating_dim))) + self.reset_parameters() + + def reset_parameters(self) -> None: + import torch.nn.init as init + init.kaiming_uniform_(self.weight, a=math.sqrt(5)) + + def forward(self, hidden_states): + bsz, seq_len, h = hidden_states.shape + hidden_states = hidden_states.view(-1, h) + logits = F.linear(hidden_states, self.weight, None) + if self.scoring_func == 'softmax': + scores = logits.softmax(dim=-1) + else: + raise NotImplementedError(f'insupportable scoring function for MoE gating: {self.scoring_func}') + + topk_weight, topk_idx = torch.topk(scores, k=self.top_k, dim=-1, sorted=False) + + if self.top_k > 1 and self.norm_topk_prob: + denominator = topk_weight.sum(dim=-1, keepdim=True) + 1e-20 + topk_weight = topk_weight / denominator + + if self.training and self.alpha > 0.0: + scores_for_aux = scores + aux_topk = self.top_k + topk_idx_for_aux_loss = topk_idx.view(bsz, -1) + if self.seq_aux: + scores_for_seq_aux = scores_for_aux.view(bsz, seq_len, -1) + ce = torch.zeros(bsz, self.n_routed_experts, device=hidden_states.device) + ce.scatter_add_(1, topk_idx_for_aux_loss, + torch.ones(bsz, seq_len * aux_topk, device=hidden_states.device)).div_( + seq_len * aux_topk / self.n_routed_experts) + aux_loss = (ce * scores_for_seq_aux.mean(dim=1)).sum(dim=1).mean() * self.alpha + else: + mask_ce = F.one_hot(topk_idx_for_aux_loss.view(-1), num_classes=self.n_routed_experts) + ce = mask_ce.float().mean(0) + Pi = scores_for_aux.mean(0) + fi = ce * self.n_routed_experts + aux_loss = (Pi * fi).sum() * self.alpha + else: + aux_loss = 0 + return topk_idx, topk_weight, aux_loss + + +class MOEFeedForward(nn.Module): + def __init__(self, config: LMConfig): + super().__init__() + self.config = config + self.experts = nn.ModuleList([ + FeedForward(config) + for _ in range(config.n_routed_experts) + ]) + self.gate = MoEGate(config) + if config.n_shared_experts is not None: + self.shared_experts = FeedForward(config) + + def forward(self, x): + identity = x + orig_shape = x.shape + bsz, seq_len, _ = x.shape + # 使用门控机制选择专家 + topk_idx, topk_weight, aux_loss = self.gate(x) + x = x.view(-1, x.shape[-1]) + flat_topk_idx = topk_idx.view(-1) + if self.training: + # 训练模式下,重复输入数据 + x = x.repeat_interleave(self.config.num_experts_per_tok, dim=0) + y = torch.empty_like(x, dtype=torch.float16) + for i, expert in enumerate(self.experts): + y[flat_topk_idx == i] = expert(x[flat_topk_idx == i]).to(y.dtype) # 确保类型一致 + y = (y.view(*topk_weight.shape, -1) * topk_weight.unsqueeze(-1)).sum(dim=1) + y = y.view(*orig_shape) + else: + # 推理模式下,只选择最优专家 + y = self.moe_infer(x, flat_topk_idx, topk_weight.view(-1, 1)).view(*orig_shape) + if self.config.n_shared_experts is not None: + y = y + self.shared_experts(identity) + self.aux_loss = aux_loss + return y + + @torch.no_grad() + def moe_infer(self, x, flat_expert_indices, flat_expert_weights): + expert_cache = torch.zeros_like(x) + idxs = flat_expert_indices.argsort() + tokens_per_expert = flat_expert_indices.bincount().cpu().numpy().cumsum(0) + token_idxs = idxs // self.config.num_experts_per_tok + # 例如当tokens_per_expert=[6, 15, 20, 26, 33, 38, 46, 52] + # 当token_idxs=[3, 7, 19, 21, 24, 25, 4, 5, 6, 10, 11, 12...] + # 意味着当token_idxs[:6] -> [3, 7, 19, 21, 24, 25, 4]位置的token都由专家0处理,token_idxs[6:15]位置的token都由专家1处理...... + for i, end_idx in enumerate(tokens_per_expert): + start_idx = 0 if i == 0 else tokens_per_expert[i - 1] + if start_idx == end_idx: + continue + expert = self.experts[i] + exp_token_idx = token_idxs[start_idx:end_idx] + expert_tokens = x[exp_token_idx] + expert_out = expert(expert_tokens).to(expert_cache.dtype) + expert_out.mul_(flat_expert_weights[idxs[start_idx:end_idx]]) + # 使用 scatter_add_ 进行 sum 操作 + expert_cache.scatter_add_(0, exp_token_idx.view(-1, 1).repeat(1, x.shape[-1]), expert_out) + + return expert_cache + + +class MiniMindBlock(nn.Module): + def __init__(self, layer_id: int, config: LMConfig): + super().__init__() + self.n_heads = config.n_heads + self.dim = config.dim + self.head_dim = config.dim // config.n_heads + self.attention = Attention(config) + + self.layer_id = layer_id + self.attention_norm = RMSNorm(config.dim, eps=config.norm_eps) + self.ffn_norm = RMSNorm(config.dim, eps=config.norm_eps) + self.feed_forward = FeedForward(config) if not config.use_moe else MOEFeedForward(config) + + def forward(self, x, pos_cis, past_key_value=None, use_cache=False): + h_attn, past_kv = self.attention( + self.attention_norm(x), + pos_cis, + past_key_value=past_key_value, + use_cache=use_cache + ) + h = x + h_attn + out = h + self.feed_forward(self.ffn_norm(h)) + return out, past_kv + + +class MiniMindLM(PreTrainedModel): + config_class = LMConfig + + def __init__(self, params: LMConfig = None): + self.params = params or LMConfig() + super().__init__(self.params) + self.vocab_size, self.n_layers = params.vocab_size, params.n_layers + self.tok_embeddings = nn.Embedding(params.vocab_size, params.dim) + self.dropout = nn.Dropout(params.dropout) + self.layers = nn.ModuleList([MiniMindBlock(l, params) for l in range(self.n_layers)]) + self.norm = RMSNorm(params.dim, eps=params.norm_eps) + self.output = nn.Linear(params.dim, params.vocab_size, bias=False) + self.tok_embeddings.weight = self.output.weight + self.register_buffer("pos_cis", + precompute_pos_cis(dim=params.dim // params.n_heads, theta=params.rope_theta), + persistent=False) + self.OUT = CausalLMOutputWithPast() + + def forward(self, + input_ids: Optional[torch.Tensor] = None, + past_key_values: Optional[List[Tuple[torch.Tensor, torch.Tensor]]] = None, + use_cache: bool = False, + **args): + past_key_values = past_key_values or [None] * len(self.layers) + start_pos = args.get('start_pos', 0) + h = self.dropout(self.tok_embeddings(input_ids)) + pos_cis = self.pos_cis[start_pos:start_pos + input_ids.size(1)] + past_kvs = [] + for l, layer in enumerate(self.layers): + h, past_kv = layer( + h, pos_cis, + past_key_value=past_key_values[l], + use_cache=use_cache + ) + past_kvs.append(past_kv) + logits = self.output(self.norm(h)) + aux_loss = sum(l.feed_forward.aux_loss for l in self.layers if isinstance(l.feed_forward, MOEFeedForward)) + self.OUT.__setitem__('logits', logits) + self.OUT.__setitem__('aux_loss', aux_loss) + self.OUT.__setitem__('past_key_values', past_kvs) + return self.OUT + + @torch.inference_mode() + def generate(self, input_ids, eos_token_id=2, max_new_tokens=1024, temperature=0.75, top_p=0.90, + stream=False, rp=1., use_cache=True, pad_token_id=0, **args): + # 流式生成 + if stream: + return self._stream(input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, **args) + + # 直接生成 + generated = [] + for i in range(input_ids.size(0)): + non_pad = input_ids[i][input_ids[i] != pad_token_id].unsqueeze(0) + out = self._stream(non_pad, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, **args) + tokens_list = [tokens[:, -1:] for tokens in out] + gen = torch.cat(tokens_list, dim=-1) if tokens_list else non_pad + full_sequence = torch.cat([non_pad, gen], dim=-1) + generated.append(full_sequence) + max_length = max(seq.size(1) for seq in generated) + generated = [ + torch.cat( + [seq, torch.full((1, max_length - seq.size(1)), pad_token_id, dtype=seq.dtype, device=seq.device)], + dim=-1) + for seq in generated + ] + return torch.cat(generated, dim=0) + + def _stream(self, input_ids, eos_token_id, max_new_tokens, temperature, top_p, rp, use_cache, **args): + start, first_seq, past_kvs = input_ids.shape[1], True, None + while input_ids.shape[1] < max_new_tokens - 1: + if first_seq or not use_cache: + out, first_seq = self(input_ids, past_key_values=past_kvs, use_cache=use_cache, **args), False + else: + out = self(input_ids[:, -1:], past_key_values=past_kvs, use_cache=use_cache, + start_pos=input_ids.shape[1] - 1, **args) + logits, past_kvs = out.logits[:, -1, :], out.past_key_values + logits[:, list(set(input_ids.tolist()[0]))] /= rp + logits /= (temperature + 1e-9) + if top_p is not None and top_p < 1.0: + sorted_logits, sorted_indices = torch.sort(logits, descending=True, dim=-1) + sorted_probs = F.softmax(sorted_logits, dim=-1) + cumulative_probs = torch.cumsum(sorted_probs, dim=-1) + sorted_indices_to_remove = cumulative_probs > top_p + sorted_indices_to_remove[:, 1:] = sorted_indices_to_remove[:, :-1].clone() + sorted_indices_to_remove[:, 0] = False + indices_to_remove = sorted_indices_to_remove.scatter(1, sorted_indices, sorted_indices_to_remove) + logits[indices_to_remove] = -float('Inf') + input_ids_next = torch.multinomial(F.softmax(logits, dim=-1), num_samples=1) + input_ids = torch.cat((input_ids, input_ids_next), dim=1) + yield input_ids[:, start:] + if input_ids_next.item() == eos_token_id: + break diff --git a/notebook/demo/pretrain_data.jsonl b/notebook/demo/pretrain_data.jsonl new file mode 100644 index 0000000..c2c691c --- /dev/null +++ b/notebook/demo/pretrain_data.jsonl @@ -0,0 +1,2 @@ +{"text": "LLM首先要学习的并非直接与人交流,而是让网络参数中充满知识的墨水,“墨水” 理论上喝的越饱越好,产生大量的对世界的知识积累。 预训练就是让Model先埋头苦学大量基本的知识,例如从Wiki百科、新闻、书籍整理大规模的高质量训练数据。 这个过程是“无监督”的,即人类不需要在过程中做任何“有监督”的校正,而是由模型自己从大量文本中总结规律学习知识点。 模型此阶段目的只有一个:学会词语接龙。例如我们输入“秦始皇”四个字,它可以接龙“是中国的第一位皇帝”。"} +{"text": "经过预训练,LLM此时已经掌握了大量知识,然而此时它只会无脑地词语接龙,还不会与人聊天。 SFT阶段就需要把半成品LLM施加一个自定义的聊天模板进行微调。 例如模型遇到这样的模板【问题->回答,问题->回答】后不再无脑接龙,而是意识到这是一段完整的对话结束。 称这个过程为指令微调,就如同让已经学富五车的「牛顿」先生适应21世纪智能手机的聊天习惯,学习屏幕左侧是对方消息,右侧是本人消息这个规律。 在训练时,MiniMind的指令和回答长度被截断在512,是为了节省显存空间。就像我们学习时,会先从短的文章开始,当学会写作200字作文后,800字文章也可以手到擒来。 在需要长度拓展时,只需要准备少量的2k/4k/8k长度对话数据进行进一步微调即可(此时最好配合RoPE-NTK的基准差值)。"} \ No newline at end of file diff --git a/notebook/demo/sft_data.jsonl b/notebook/demo/sft_data.jsonl new file mode 100644 index 0000000..e037570 --- /dev/null +++ b/notebook/demo/sft_data.jsonl @@ -0,0 +1,2 @@ +{"conversations":[{"role":"user","content":"你好吗?"},{"role":"assistant","content":"我很好,谢谢!你呢?"},{"role":"user","content":"我也很好,谢谢!"},{"role":"assistant","content":"太好了!祝你今天愉快!"}]} +{"conversations":[{"role":"user","content":"你喜欢什么运动?"},{"role":"assistant","content":"我喜欢跑步和游泳。你呢?"},{"role":"user","content":"我喜欢打篮球!"},{"role":"assistant","content":"篮球很棒!是一个很好的团队运动。"}]} diff --git a/notebook/demo/tokenizer_data.jsonl b/notebook/demo/tokenizer_data.jsonl new file mode 100644 index 0000000..26e183d --- /dev/null +++ b/notebook/demo/tokenizer_data.jsonl @@ -0,0 +1,3 @@ +{"text": "近年来,人工智能技术迅速发展,深刻改变了各行各业的面貌。机器学习、自然语言处理、计算机视觉等领域的突破性进展,使得智能产品和服务越来越普及。从智能家居到自动驾驶,再到智能医疗,AI的应用场景正在快速拓展。随着技术的不断进步,未来的人工智能将更加智能、更加贴近人类生活。"} +{"text": "在数据时代,信息的流通和处理速度决定了一个国家的竞争力。随着大数据技术和云计算的普及,企业和政府能够更高效地利用信息资源,实现精细化管理和决策,1234567890qwertyuiopasdfhgjklzxbcdljfasjdfaflsdjffa深度发掘的链接发的饭卡京东方卡拉快递费发动快速开发设计大大大大大大顶顶顶。"} +{"text": "一个卖瓷碗的老人挑着扁担在路上走着,突然一个瓷碗掉到地上摔碎了,但是老人头也不回继续向前走。路人看到很奇怪,便问:“为什么你的碗摔碎了你却不看一下呢?”老人答到:“我再怎么回头看,碗是碎的。”失去的东西就要学着去接受,学着放下。很多事并不会因为你的悲伤就会回来,结果就会被改变。"} \ No newline at end of file