揭密ChatGPT背后的技能与应用

一、chatGPT的发展史

        阅读本篇文章前需要先了解一下chatGPT的一个发展历程,只有先从广义上有个大概框架认识,才能落地到每个重要的节点和知识概念。有句话说,“所有的新技术”都是基于某一种技术的革命和超越,所以在学习一个新的技术前必须先了解一下它的前世今生,可以不用全部都学,但是每个知识点需要至少用自己的方式去了解一下。

  1. 起源与词向量

    在谈论 chatGPT 的发展史时,我们需要先从词向量(Word Embeddings)的出现说起。词向量是将单词映射到连续向量空间中的表示形式,使得单词的语义信息能够被更好地捕捉和理解。其中,Word2Vec、GloVe、FastText 等模型为词向量的发展提供了重要基础。
  2. 循环神经网络(RNN)与序列模型

    随着对自然语言处理(NLP)任务的需求增加,循环神经网络(RNN)成为了一个重要的工具,尤其是在处理序列数据时。RNN 可以捕捉序列数据中的时间依赖关系,从而在自然语言理解任务中表现出色。
  3. 长短期记忆网络(LSTM)与门控循环单元(GRU)

    随着对 RNN 的发展,人们意识到传统的 RNN 存在梯度消失和梯度爆炸等问题。为了解决这些问题,长短期记忆网络(LSTM)和门控循环单元(GRU)被提出。它们通过引入门控机制,有效地处理了长期依赖关系,成为了更为稳定和强大的模型。
  4. 注意力机制(Attention Mechanism)

    注意力机制的提出使得模型能够更加灵活地关注输入序列中的不同部分,从而提高了模型在处理长序列和复杂序列数据时的性能。Transformer 模型的成功应用将注意力机制推向了前沿。
  5. Transformer 模型

    Transformer 模型的问世标志着 NLP 领域的一次重大突破。它摒弃了传统的循环神经网络结构,采用了自注意力机制(Self-Attention)和多头注意力机制(Multi-Head Attention),极大地提升了模型的性能和并行计算能力。
  6. 预训练模型的兴起

    随着大规模预训练模型(如BERT、GPT)的兴起,自然语言处理领域取得了巨大的进展。这些模型通过在大规模文本数据上进行无监督的预训练,然后在特定任务上进行微调,取得了惊人的效果,成为了 NLP 领域的新标杆。
  7. GPT 系列模型的发展

    GPT(Generative Pre-trained Transformer)系列模型由 OpenAI 提出,它们以 Transformer 模型为基础,采用了自回归语言模型(Autoregressive Language Model)的框架,通过无监督的预训练学习了大规模文本数据的语言表示,具备了强大的生成能力和语义理解能力。
  8. GPT-3 的突破

    GPT-3 是 GPT 系列中规模最大的模型,拥有 1750 亿个参数。它展示了惊人的生成能力和泛化能力,能够在各种自然语言处理任务上取得令人瞩目的表现,引发了对于大规模预训练模型的广泛关注和研究。

二、GPT初始技术词向量原理与实践

词向量概念理论知识解析

词向量(Word Embeddings)是自然语言处理(NLP)中常用的一种表示方法,它将词汇表中的每个单词映射成一个定长的向量,这个向量通常存在于一个连续的高维空间中。词向量的主要目的是将自然语言中的离散符号转化为机器可以理解的数值形式,使得词与词之间的语义和语法关系可以通过向量间的数学运算得以体现。好的词向量模型能够捕捉到单词的上下文信息,使得语义相近的词在向量空间中的距离也较近。

那词向量与gpt的关系是什么呢?

词向量 想象一下,我们把每一个单词看作是一个独一无二的人,而有一个神秘的空间可以把每个人安置在一个特定的位置上。在这个空间里,不同位置代表不同的特征,比如身高、体重、性格等。同样地,在NLP领域,每个单词也可以被转换成一个“数值人”,放在一个高维空间里的一个点上,这就是词向量。

例如,“猫”、“狗”、“宠物”这三个词在词向量空间中,可能会彼此靠近,因为它们在语义上有相关性;而“猫”和“飞机”则可能距离较远,因为它们通常不共享相似的语境含义。这样一来,原本难以直接计算和比较的词语,现在可以通过向量的加减乘除等数学操作找到它们之间的某种联系。

GPT就像是一个非常聪明的故事讲述者,他不仅了解每个词汇的意思(通过词向量),还能根据上下文环境灵活调整对词汇的理解,并预测接下来可能的故事发展。

具体来说,当GPT开始处理一句话时,它首先会将句子中的每个单词都变换成对应的词向量,就像给每个单词穿上了一件数字化的衣服。然后,GPT内部有一套复杂的机制(Transformer结构),能够根据每个词向量在整个句子中的位置和其他词的关系,动态地更新每个词的含义。

比如在预训练阶段,GPT会学习大量文本数据,通过这种学习,它能理解“我最喜欢的宠物是猫”这句话中,“猫”和“宠物”之间有很强的关联性。而在后续的任务如生成新文本时,如果遇到类似上下文,GPT就很可能根据之前学到的规律,预测出下一个可能出现的是跟“猫”或“宠物”类似的词汇。

所以,词向量为GPT提供了理解和处理自然语言的基本元素,而GPT通过深度学习技术进一步挖掘并利用这些元素间的关系来进行文本生成等各种任务。

ngram语言模型

n-gram 模型就是一类最简单的语言模型,它基于马尔科夫假设(Markov assumption),即一个词出现的概率仅与其前面有限个词有关,而不依赖于更早之前的词。

在自然语言处理中,n-gram 是指一个连续出现的n个项目的序列,这里的项目通常指的是文本中的单个词汇、字符或音素。

想象一下,正在教一个小机器人如何学习人类说话的方式。n-gram就好比是小机器人学习的“短句规则”。

  1. n-gram是什么? 假设你在教机器人一句简单的日常对话:“我爱你”。如果我们按照单个字来看,这是一个“一元gram”(unigram)的序列:“我”、“爱”、“你”。如果按照两个字一组来看,就是一个“二元gram”(bigram)的序列:“我爱你”可以拆分为“我爱”和“爱你”。如果看三个字一组,那就是“三元gram”(trigram):“我爱”、“爱你”。

  2. n-gram与语言模型的关系 现在,我们要让机器人学会如何自己生成类似的对话。这就需要用到语言模型。我们可以建立一个基于n-gram的语言模型,告诉机器人:“在一般情况下,如果一个人说了'我'之后,很可能会跟着说'爱',然后是'你'。”

    所以,n-gram语言模型就是这样一个“统计规律”的集合,它记录下在大量真实文本中各个词组(n-gram)出现的频率,然后根据这些频率来预测下一个词可能是什么。例如,模型发现“我爱”后面接“你”的情况非常多,于是当只看到“我爱”时,模型就会认为下一个词是“你”的概率很大。

然而,真实的语言并不总是如此简单线性,有时候我们需要考虑更复杂的上下文关系。传统的n-gram模型只能记住前几个词的影响,对于更长距离的上下文信息处理能力有限。而更先进的神经网络语言模型(如GPT)则像一个记忆力更强、思考更全面的机器人,它可以理解和记忆更长的上下文,从而生成更准确和流畅的文本。

Word2Vec神经网络模型

Word2Vec 是一个用于自然语言处理的神经网络模型,由 Google 的 Mikolov 等人在 2013 年提出,主要用于将自然语言中的单词映射为连续向量空间中的向量,这些向量称为词嵌入(word embeddings)。通过训练,Word2Vec 能够捕捉到单词之间的语义和语法关系,使得具有相似含义或经常出现在相似上下文中的单词在向量空间中彼此接近。

CBOW

CBOW (Continuous Bag-of-Words) 是 Word2Vec 提出的两种核心模型之一。它的基本思想是利用一个词的上下文(即周围的词)来预测该词本身。CBOW模型通过观察大量的上下文样本,学习到一种紧凑且有意义的词向量表示,使得模型能够根据给定的一组上下文词快速有效地预测出目标词,同时这些词向量在很多实际应用中展现出优秀的通用性和可解释性。

Skip-gram

Skip-gram模型通过大量语料库训练,使得语义上相似的词在向量空间中的距离更近,从而实现对词汇的高效且富有含义的数学表示。这些词嵌入不仅可用于诸如文本分类、情感分析等多种自然语言处理任务,还可以揭示潜在的语义结构,如通过计算词向量之间的余弦相似度发现同义词或类比关系。

Skip-gram和CBOW的区别

        Skip-gram是用一个词去预测上下两个词,CBOW是用上下两个词去平均,然后预测下一个词。

  1. 训练目标与方向:

    • CBOW:模型的目标是根据一个词的上下文(周围的词)来预测中心词。换句话说,CBOW是基于上下文来推测当前单词,模型的输入是上下文词向量的平均或加权平均,输出是中心词的预测概率分布。
    • Skip-gram:模型则反过来,其目标是根据一个中心词来预测其周围的上下文词。也就是说,Skip-gram是利用当前单词来推测其前后出现的其他单词,对于每个中心词,模型会独立地对每个上下文词进行预测。
  2. 训练过程和效果:

    • CBOW:由于它综合了上下文信息,因此在训练过程中,上下文词的信号会被合并在一起,对于高频词和低频词的区分可能不如Skip-gram明显,特别是在处理稀有词汇时,CBOW可能无法提供足够精确的词向量。
    • Skip-gram:相比CBOW,Skip-gram更强调每个中心词与每个上下文词之间的独立关系,这使得模型在训练过程中需要做更多的预测任务,但同时也能够更细致地捕捉到单词之间的联系。因此,Skip-gram在某些情况下可能产生更好的词向量表示,特别是在处理低频词时,由于每个中心词都单独影响了上下文词的预测,因此对低频词的学习更有利。
  3. 计算和训练效率:

    • CBOW:由于它直接使用上下文词的均值向量作为输入,所以在计算量上相对于Skip-gram较小,训练速度可能较快,特别在大数据集上表现得更为明显。
    • Skip-gram:由于每次迭代都需要为每个中心词和多个上下文词组合执行多次预测,所以其计算量相对较大,训练时间可能较长,但它对单个词的学习更为细致,精度有时会更高。

CBOW和Skip-gram各有优势和适用场景,选择哪种模型通常取决于所需的应用特性和可用的数据资源。在实践中,二者都可以生成高质量的词嵌入,但具体表现会因任务需求的不同而有所差异。

Softmax函数

 Softmax函数是机器学习和深度学习中用于多类别分类问题的一个重要激活函数。给定一组未归一化的分数(通常是神经网络最后一层的输出),softmax函数会将其转化为一个概率分布。具体公式如下:

这里的 i 表示第 i 个类别,输出结果是一个K维概率向量,每个元素都在 [0,1][0,1] 范围内且所有元素之和为1。在自然语言处理中的词嵌入模型(如Word2Vec的Skip-gram模型)中,softmax层用于计算给定中心词条件下所有词汇作为上下文词的概率分布。 

Softmax树形优化

Softmax树形优化(通常称为层次Softmax或Hierarchical Softmax)是一种专门针对大规模多分类问题中Softmax函数计算瓶颈的优化技术,特别适用于像Word2Vec这样的词嵌入模型。在Word2Vec中,softmax层用于计算一个中心词与词汇表中所有词作为上下文词的概率分布。然而,当词汇表很大时,计算所有词汇的概率及相应的梯度代价高昂。

层次Softmax采用了树形数据结构(如霍夫曼树或二叉树)来替代传统的Softmax计算。在这个树结构中,每一个词汇都被映射为树的一个叶子节点。计算任一中心词与某个词汇作为上下文词的概率时,不需要遍历整个词汇表,而是沿着树从根节点到相应叶节点的路径来进行概率计算。

具体而言,每个内部节点代表一个二元决策,通过这个决策过程逐步确定最终到达哪个叶子节点(即词汇)。每条从根节点到叶子节点的路径对应一个概率累积过程,通过对路径上各内部节点的条件概率乘积即可得到最终词汇的概率。

通过这种方式,层次Softmax显著降低了计算复杂性和内存消耗,因为它只需对树的深度进行遍历,而非词汇表的大小。在实践中,层次Softmax极大地加快了训练速度,特别是对于拥有大量词汇的自然语言处理任务。

负采样优化

在训练词嵌入模型时,尤其是Word2Vec,传统的Softmax函数需要对整个词汇表计算概率分布,这在词汇表非常庞大的情况下计算成本非常高昂。为了解决这个问题,Mikolov等人提出了负采样(Negative Sampling)方法作为一种优化策略。

负采样不是直接计算所有词汇的概率,而是对每个正例(中心词与正确上下文词的配对)仅随机抽样一小部分负例(中心词与其他词汇的错误配对)。在每次迭代更新模型参数时,目标是最大化中心词与其正确的上下文词之间的共现概率,同时最小化中心词与负例词汇之间的共现概率。

这种方法大大减少了计算量,因为它仅需要优化少量(通常是5到20个)随机选取的负例,而不是整个词汇表。这样既保持了模型学习词向量的有效性,又极大地提升了训练速度和效率。因此,负采样成为了大规模自然语言处理任务中训练词嵌入模型时的一项重要优化技术。

Word2Vec代码实践

数据预处理
下载训练需要的数据
import io
import os
import sys
import requests
from collections import OrderedDict
import math
import random
import numpy as np


# 下载语料用来训练word2vec
def download_corpus():
    # 使用百度云服务器上的开源数据集链接
    corpus_url = "https://dataset.bj.bcebos.com/word2vec/text8.txt"
    
    # 使用requests库下载数据集
    response = requests.get(corpus_url)
    
    # 将下载的内容写入本地的text8.txt文件
    with open("./text8.txt", "wb") as f:
        f.write(response.content)
        
# 调用函数下载语料
download_corpus()


#打开下载好的文本文件
def load_txt():
    with open('./text8.txt', 'r') as f:
        corpus = f.read().strip('\n')
    f.close()
    return corpus

corpus = load_txt()


#数据预处理,分词&大写转小写
def data_preprocess(corpus):
    corpus = corpus.strip().lower()
    corpus = corpus.split(' ')
    return corpus
corpus = data_preprocess(corpus)
统计词频,并构建词频字典
# 统计词频,并构建词频dict
def build_dict(corpus):
    # 初始化一个空字典用于存储单词频率
    word_freq_dict = {}
    
    # 遍历语料中的每个单词
    for word in corpus:
        # 如果单词不在字典中,则将其添加,并初始化频率为0
        if word not in word_freq_dict:
            word_freq_dict[word] = 0
        # 更新单词频率
        word_freq_dict[word] += 1

    # 将字典按照值(频率)从大到小排序,并转换为列表
    word_freq_dict = sorted(word_freq_dict.items(), key=lambda x: x[1], reverse=True)
    
    # 初始化单词到ID的映射字典
    word2id_dict = {}
    # 初始化ID到单词的映射字典
    id2word_dict = {}
    # 初始化单词ID到频率的映射字典
    word2id_freq = {}
    
    # 遍历排好序的单词频率字典
    for word, freq in word_freq_dict:
        # 获取当前单词在字典中的ID
        curr_id = len(word2id_dict)
        # 将单词与对应的ID添加到单词到ID的映射字典中
        word2id_dict[word] = curr_id
        # 将单词ID与对应的频率添加到单词ID到频率的映射字典中
        word2id_freq[curr_id] = freq
        # 将ID与对应的单词添加到ID到单词的映射字典中
        id2word_dict[curr_id] = word
    
    # 返回单词ID到频率的映射字典、单词到ID的映射字典、ID到单词的映射字典
    return word2id_freq, word2id_dict, id2word_dict

# 从语料构建词典
word2id_freq, word2id_dict, id2word_dict = build_dict(corpus)
vocab_size = len(word2id_dict)
# 输出前10个单词及其ID和频率
for _, (word, word_id) in zip(range(10), word2id_dict.items()):
    print('%s %s %s'%(word, word_id, word2id_freq[word_id]))


# 把语料转换为id序列
def convert_corpus2id(corpus, word2id_dict):
    # 遍历语料中的每个单词,并通过字典查询将其转换为ID序列
    corpus = [word2id_dict[word] for word in corpus]
    return corpus

# 将语料转换为单词ID序列
corpus = convert_corpus2id(corpus, word2id_dict)
print(corpus[:20])  # 打印前20个转换后的单词ID序列
生成模型训练所需要的样本 

# 构造样本函数
def build_data(corpus, word2id_dict, word2id_freq, max_windows_size=3, negative_sample_num=4):
    dataset = []  # 初始化样本数据集列表
    center_word_idx = 0  # 初始化中心单词索引为0

    # 循环直到遍历完整个语料
    while center_word_idx < len(corpus):
        window_size = random.randint(1, max_windows_size)  # 随机选择一个窗口大小
        context_word = corpus[center_word_idx]  # 获取中心单词

        # 确定标签词的索引范围,以避免窗口超出语料长度
        label_start = max(0, center_word_idx - window_size)
        label_end = min(len(corpus) - 1, center_word_idx + window_size)

        # 生成标签候选词列表,排除中心词本身
        label_candidates = [corpus[idx] for idx in range(label_start, label_end + 1) if idx != center_word_idx]

        # 对于每个标签词,构建正样本和负样本
        for label_word in label_candidates:
            # 添加正样本,标签为1
            dataset.append((context_word, label_word, 1))
            
            # 随机生成负样本
            i = 0
            while i < negative_sample_num:
                neg_candidate = random.randint(0, vocab_size - 1)  # 从词汇表中随机选择一个词作为负样本候选
                if neg_candidate != label_word:  # 确保负样本不是标签词本身
                    dataset.append((context_word, neg_candidate, 0))  # 添加负样本,标签为0
                    i += 1

        center_word_idx += window_size  # 更新中心单词索引,以跳过窗口内已经处理过的单词
    return dataset  # 返回构建好的样本数据集

# 使用构造好的函数构建样本数据集
dataset = build_data(corpus, word2id_dict, word2id_freq)

# 打印前20个样本以供查看
print(dataset[:20])
数据预处理总结 

在构建一个词嵌入模型(Word Embedding Model)时,预处理过程非常关键,它包括了以下几个步骤:

  1. 统计词频: 在预处理阶段,我们首先需要统计语料中每个单词出现的频率。这个过程可以通过遍历语料,对每个单词进行计数来实现。为什么要统计词频呢?因为在训练词嵌入模型时,通常会根据单词的频率进行一些处理,比如对高频词或低频词进行特殊处理,以提高模型的性能和泛化能力。

  2. 构建词典: 统计完词频后,我们需要构建一个词典,将每个单词映射到一个唯一的ID,这样模型在训练时就可以使用单词的ID来表示单词。同时,我们还需要将单词ID映射回单词,以便在需要时进行反向转换。构建词典的目的是为了将自然语言文本转换为模型可以处理的数字形式。

  3. 生成样本: 在构建样本时,我们需要确定窗口大小、中心词以及构建正样本和负样本。这一步是为了在训练过程中生成模型所需的训练样本。通常情况下,我们会选择一个窗口大小,确定一个中心词,然后从该中心词周围的词中构建正样本,并从整个词汇表中随机选择单词作为负样本。这个过程可以帮助模型学习到单词之间的语义关系。

为了更清晰地理解,进一步解释生成样本的过程:

  • 窗口:窗口大小决定了从中心词周围选择多少个单词作为上下文。选择合适的窗口大小有助于捕捉到单词之间的语义关联。例如,如果窗口大小为3,则意味着我们会考虑中心词左右各3个单词作为上下文单词。

  • 中心词:中心词是我们当前要考虑的单词,我们将会以它为中心构建训练样本。模型会试图根据中心词和其周围的上下文单词来学习单词的语义信息。

  • 正样本和负样本:为了训练模型,我们需要提供给模型一些示例,让它学习单词之间的关系。在这里,我们将中心词与其上下文单词(正样本)配对,同时还会随机选择一些单词作为负样本。正样本的目的是让模型学习到正确的单词关联,而负样本则用于训练模型区分正确的单词对和错误的单词对。

通过以上步骤,我们可以将原始的自然语言文本预处理成模型可以直接使用的训练样本,从而训练出能够表示单词语义信息的词嵌入模型。

SkipGram模型

SkipGram介绍

Skip-Gram 模型是 word2vec 中的一种,它是 word2vec 中的一种基于神经网络的词嵌入模型。

具体来说,word2vec 提出了两种模型:Skip-Gram 模型和连续词袋(Continuous Bag of Words,简称 CBOW)模型。这两种模型都是基于神经网络的方法,用于学习单词的向量表示。

  • Skip-Gram 模型:Skip-Gram 模型的基本思想是通过预测一个词的上下文来学习单词的向量表示。具体而言,给定一个中心词,Skip-Gram 模型试图预测在给定窗口大小内可能出现的上下文单词,从而学习到单词的语义信息。Skip-Gram 模型的训练目标是最大化给定中心词的条件下,预测其周围上下文单词的概率。

  • CBOW 模型:与 Skip-Gram 模型相反,CBOW 模型是根据上下文单词预测中心词。它的训练目标是最大化给定上下文单词的条件下,预测中心词的概率。

总的来说,Skip-Gram 模型更适合于大型语料库和稀疏数据集,而 CBOW 模型更适合于小型语料库和密集数据集。它们都是 word2vec 的一部分,用于学习单词的向量表示,并在自然语言处理任务中广泛应用,例如语义分析、信息检索、情感分析等。

SkipGram代码实践
import paddle
from paddle import nn
import paddle.nn.functional as F

# 定义 SkipGram 模型类,继承自 paddle.nn.Layer
class SkipGram(nn.Layer):
    def __init__(self, vocab_size, embedding_size):
        super(SkipGram, self).__init__()
        self.vocab_size = vocab_size  # 词汇表大小
        self.embedding_size = embedding_size  # 嵌入维度

        # 定义嵌入层,指定词汇表大小和嵌入维度
        # 使用正态分布初始化嵌入权重(均值为 0,标准差为 0.5)
        self.embedding = nn.Embedding(vocab_size, embedding_size, weight_attr=paddle.ParamAttr(initializer=nn.initializer.Normal(mean=0.0, std=0.5)))
    
    def forward(self, center_word, target_word, label):
        # 获取中心词和目标词的嵌入表示
        center_word_emb = self.embedding(center_word)
        target_word_emb = self.embedding(target_word)
        
        # 计算中心词与目标词的相似度
        word_sim = center_word_emb * target_word_emb
        word_sim = paddle.sum(word_sim, axis=-1, keepdim=False)
        
        # 使用 Sigmoid 函数将相似度转换为概率
        pred = F.sigmoid(word_sim)

        # 计算二元交叉熵损失
        loss = F.binary_cross_entropy(pred, label)
        return pred, loss

# 超参数设置
batch_size = 256  # 批量大小
epoch_num = 3  # 迭代次数
embedding_size = 256  # 嵌入维度
step = 0  # 当前迭代步数(用于记录训练过程)
learning_rate = 0.0005  # 学习率

# 导入 numpy
import numpy as np

# 定义计算余弦相似度的函数
def get_cos(token1, token2, W):
    # 获取 token1 和 token2 的词向量
    x = W[word2id_dict[token1]]
    y = W[word2id_dict[token2]]
    
    # 计算余弦相似度
    cos = np.dot(x, y) / np.sqrt(np.sum(y*y)*np.sum(x*x) + 1e-9)
    flat = cos.flatten()
    print('%s vs %s %f'%(token1, token2, cos))

# 导入 Adam 优化器
from paddle.optimizer import Adam

# 初始化 SkipGram 模型和 Adam 优化器
model = SkipGram(vocab_size, embedding_size)
adam = Adam(learning_rate=learning_rate, parameters=model.parameters())
# 循环迭代训练模型
for center_word, target_word, label in build_batch(dataset, batch_size, epoch_num):
    # 将数据转换为张量
    center_word = paddle.to_tensor(center_word)
    target_word = paddle.to_tensor(target_word)
    label = paddle.to_tensor(label)
    
    # 前向传播,计算预测值和损失
    pred, loss = model(center_word, target_word, label)
    
    # 反向传播,计算梯度并更新参数
    loss.backward()
    adam.step()
    adam.clear_grad()
    step += 1
    
    # 每训练100个步骤,输出一次损失
    if step % 100 == 0:
        print('step %d, loss %.3f' % (step, loss.numpy()))
    
    # 每训练500个步骤,保存嵌入并计算余弦相似度
    if step % 500 == 0:
        # 保存嵌入
        embed = model.embedding.weight.numpy()
        np.save('./embedding', embed)
        
        # 计算余弦相似度
        get_cos('she', 'her', embed)
        get_cos('one', 'name', embed)

# 加载保存的嵌入
embed = np.load('./embedding.npy')
# 计算指定词对的余弦相似度
get_cos('she', 'her', embed)
get_cos('one', 'name', embed)

首先,第一段代码是模型的训练过程。它会不断地从数据集中抽取小批量数据,然后用这些数据来训练模型。在训练的过程中,模型会根据输入的中心词和目标词来预测它们之间的关系,并且根据预测结果来调整模型的参数,以使预测结果更接近实际标签。训练过程中会输出训练过程中的损失值,并且定期保存模型中的词向量(也就是嵌入),以便后续评估使用。

第二段代码是模型的评估过程。在这个过程中,首先加载了之前保存的模型中的词向量,然后利用这些词向量来计算特定词对的余弦相似度。余弦相似度是衡量两个向量方向相似程度的指标,可以用来衡量两个词在语义上的相似程度。通过计算余弦相似度,我们可以了解模型学到的词向量对于这些词对之间的关系的表现如何。

综合来说,这两段代码一起完成了一个单词嵌入模型的训练和评估过程,通过训练模型,我们可以得到词向量,并且通过评估词向量的质量,我们可以了解模型学到的词向量在语义上的表达能力如何。

三、RNN、LSTM 和 GRU

RNN、LSTM 和 GRU 是三种常用的用于处理序列数据的神经网络结构。它们都通过保持状态信息的方式来捕捉序列数据中的长期依赖关系,但在实际应用中,由于梯度消失和梯度爆炸等问题的存在,LSTM 和 GRU 往往能够更好地处理长序列数据,并且在一些任务上表现得更优秀。

在 chatGPT 的发展历程中,这些模型都扮演了重要的角色,为后续的模型提供了重要的基础和启发。通过了解这些模型的原理和特点,我们可以更好地理解 chatGPT 模型的设计和优化过程。

循环神经网络(RNN)

        循环神经网络是一种专门设计用于处理序列数据的神经网络结构。其独特之处在于,它能够利用前一个时间步的输出作为当前时间步的输入,从而使得网络能够在处理序列数据时保持状态信息。但传统的 RNN 存在着梯度消失和梯度爆炸等问题,这限制了其在处理长序列时的性能。

        例子:语言建模(Language Modeling)

        在语言建模任务中,我们希望模型能够根据前面的单词预测下一个单词出现的概率。RNN 在这个任务中能够捕捉到单词之间的时间依赖关系,因为它会在每个时间步使用上一个时间步的隐藏状态作为输入,从而保持了状态信息。这使得 RNN 能够很好地适用于需要考虑上下文信息的自然语言处理任务,比如语言生成、机器翻译等。

长短期记忆网络(LSTM)

长短期记忆网络是为了解决传统 RNN 的梯度消失和梯度爆炸问题而提出的一种变种网络结构。它通过引入门控单元(gate units)来控制信息的流动,从而有效地捕捉和传递序列数据中的长期依赖关系。LSTM 的核心思想是通过遗忘门、输入门和输出门来控制网络状态的更新,从而更好地处理长序列数据。

例子:情感分析(Sentiment Analysis)

在情感分析任务中,我们希望模型能够根据文本的内容判断其表达的情感是正面的、负面的还是中性的。LSTM 在这个任务中能够更好地捕捉到文本中的长期依赖关系,从而更准确地理解文本的情感信息。LSTM 中的门控机制可以帮助网络记住重要的上下文信息,以便更好地进行情感分类。

门控循环单元(GRU)

门控循环单元是另一种为了解决传统 RNN 的问题而提出的变种网络结构。它和 LSTM 类似,同样通过引入门控机制来控制信息的流动,但相比于 LSTM,GRU 的结构更为简单。GRU 合并了遗忘门和输入门,同时减少了参数的数量,使得模型更容易训练,并且在一些任务上表现得和 LSTM 相当甚至更好。

例子:命名实体识别(Named Entity Recognition)

        在命名实体识别任务中,我们希望模型能够识别文本中的特定实体,比如人名、地名、组织名等。GRU 在这个任务中表现出色,因为它的结构相对简单,参数数量较少,训练速度更快。而且,GRU 中的门控机制可以有效地控制信息的流动,帮助网络更好地理解文本的上下文信息,从而提高了命名实体识别的准确性和效率。

四、注意力机制和Transformer 模型

Seq2Seq 结构和注意力机制(Seq2Seq with Attention)

概念知识

注意力机制是一种模仿人类视觉系统的思想,使得模型能够在处理序列数据时更加灵活地关注输入序列中的不同部分。在自然语言处理中,注意力机制允许模型动态地将注意力集中在输入序列中的不同位置,以便更好地理解和处理序列数据。

工作原理

在注意力机制中,模型会学习到一组权重,用来衡量输入序列中每个位置的重要性。这些权重会根据当前时间步的输入和上下文信息动态调整,以便模型能够在不同时间步关注不同部分的输入序列。这样,模型就能够更好地捕捉到序列数据中的关键信息,从而提高了模型在各种自然语言处理任务中的性能。

Transformer 模型

概念知识

Transformer 模型是由 Vaswani 等人于2017年提出的一种基于注意力机制的神经网络架构,用于处理序列到序列(Sequence-to-Sequence)的任务,比如机器翻译、文本生成等。与传统的循环神经网络不同,Transformer 模型完全摒弃了循环结构,而是采用了自注意力机制(Self-Attention)和位置编码(Positional Encoding)来捕捉序列中的信息。

工作原理

在 Transformer 模型中,自注意力机制允许模型同时考虑输入序列中的所有位置,并且根据每个位置的重要性动态地分配注意力权重。通过堆叠多层自注意力机制和前馈神经网络层,Transformer 模型能够学习到输入序列中不同位置之间的复杂依赖关系,从而实现了对长序列数据的高效处理。

Multi-Head Attention 多头注意力机制

在 Transformer 模型中,多头注意力机制允许模型同时关注输入序列中的不同子空间信息,从而更好地捕捉序列中的长距离依赖关系。它通过将注意力机制拆分成多个头(head)并行处理不同的子空间,然后将它们的输出拼接在一起,以提高模型的表征能力。

注意力机制

在传统的注意力机制中,我们计算注意力权重时,会使用一个注意力分数函数将查询(query)和键(key)之间的相似性映射到一个权重。然后,根据这些权重对值(value)进行加权求和,以产生最终的注意力输出。

多头注意力机制的工作流程

在多头注意力机制中,我们可以将输入的查询(Q)、键(K)和值(V)在不同的“注意力头”上进行处理,每个注意力头都有自己的权重矩阵。这样,模型可以并行地学习多个不同的表示,并将它们合并起来以得到最终的输出。

1. 投影(Projection):

首先,我们需要将输入的查询、键和值分别投影到多个子空间中。这个投影是通过将输入与对应的权重矩阵相乘来完成的。这样,我们就得到了每个注意力头上的查询、键和值的投影。

2. 计算注意力(Attention Calculation):

对于每个注意力头,我们使用投影后的查询、键和值来计算注意力权重。这个计算过程与传统的注意力机制相同,我们计算查询与键的点积,然后通过 softmax 函数将得分归一化,最后将归一化的权重与值相乘以获得注意力输出。

3. 合并(Concatenation):

在计算了所有注意力头的输出后,我们将它们拼接在一起。这个拼接操作将每个注意力头的输出按照一定的顺序连接在一起,形成一个更大的张量。

4. 线性变换(Linear Transformation):

最后,我们通过另一个线性变换将多个头的输出合并到一个输出中。这个线性变换的作用是将拼接在一起的多个头的输出进行综合,以获得最终的多头注意力输出。

总的来说,多头注意力机制允许模型在不同的子空间上并行处理输入序列的信息,并通过合并多个头的输出来获得更丰富的表示。这使得模型能够更好地捕捉序列中的长距离依赖关系,从而提高了模型的性能。

Decoder 解码器

Transformer 模型的解码器通过堆叠多层自注意力机制和多头注意力机制来生成目标序列。在生成过程中,解码器会在每个时间步利用编码器的输出和自身的输出来动态地生成目标序列。

Sparse Transformer 稀疏模型

Sparse Transformer 是 Transformer 模型的一种改进,它引入了稀疏注意力机制,以降低模型的计算复杂度和内存消耗。通过限制注意力权重的计算范围,Sparse Transformer 能够在保持性能的同时大幅减少计算资源的使用。

Transformer-XL 解决长序列的问题

Transformer-XL 是针对 Transformer 模型在处理长序列时存在的局部上下文信息丢失问题而提出的改进。它通过将上一层的隐藏状态作为下一层的输入,从而保留了更长的上下文信息,使得模型能够更好地处理长序列数据。

五、预训练模型的兴起

预训练模型 BERT

BERT(Bidirectional Encoder Representations from Transformers)是由Google在2018年提出的一种预训练语言模型,它在自然语言处理领域引起了巨大的轰动。下面我将详细解释BERT的原理、训练过程和应用。

1. BERT的原理:

BERT的核心思想是使用大规模无标注的文本数据进行预训练,然后在特定任务上进行微调,从而获得在该任务上的优秀性能。BERT模型的特点如下:

  • 双向性(Bidirectional):传统的语言模型(如基于RNN的模型)只能从左到右或从右到左单向地处理输入序列,而BERT利用了Transformer模型的特点,同时考虑了输入序列中左右两侧的上下文信息,从而实现了双向性。

  • 无监督预训练:BERT的预训练过程包括两个任务:Masked Language Model(MLM)和Next Sentence Prediction(NSP)。在MLM任务中,模型需要预测输入序列中部分单词被mask后的原始单词;在NSP任务中,模型需要判断两个输入句子是否是连续的。

  • Transformer架构:BERT使用了Transformer模型,其中包括多层的编码器(Encoder),利用自注意力机制(Self-Attention)来捕捉输入序列中的上下文信息。

2. BERT的训练过程:

BERT的训练分为两个阶段:预训练和微调。

  • 预训练(Pre-training):使用大规模的无标注文本数据(如Wikipedia语料库等)进行预训练,模型通过MLM和NSP任务来学习语言的表示。在MLM任务中,一部分输入序列的单词被mask掉,模型需要根据上下文信息来预测被mask的单词;在NSP任务中,模型需要判断两个输入句子是否相邻。

  • 微调(Fine-tuning):在特定的下游任务上,如文本分类、命名实体识别等,使用有标注的数据对BERT模型进行微调,使其适应具体的任务。

3. BERT的应用:

BERT模型在自然语言处理领域的各种任务中都取得了令人瞩目的成果,包括但不限于:

  • 文本分类:利用BERT模型进行文本分类任务,如情感分析、垃圾邮件识别等。

  • 命名实体识别(NER):通过微调BERT模型,在命名实体识别任务中取得了较好的效果,能够识别文本中的人名、地名、组织名等实体。

  • 问答系统:在问答系统中,BERT模型可以理解问题和文本段落的语义信息,并生成准确的回答。

  • 文本生成:利用BERT模型进行文本生成任务,如摘要生成、对话生成等,能够生成流畅、连贯的文本。

4. BERT的优势:
  • 表征能力强:BERT模型能够学习到丰富的语言表示,具有很强的泛化能力。

  • 多语言通用:BERT模型在预训练阶段使用大规模无标注的文本数据,因此可以轻松应用于多种语言的自然语言处理任务。

  • 易于使用:由于BERT已经在大规模的数据上进行了预训练,因此在特定任务上进行微调时,只需使用较少的有标注数据即可取得良好的效果。

BERT 情感分析实践

# 导入必要的库
from functools import partial
import numpy as np

# 导入PaddlePaddle和PaddleNLP库
import paddle
from paddlenlp.datasets import load_dataset
from paddlenlp.transformers import AutoModelForSequenceClassification, AutoTokenizer
from paddlenlp.transformers import BertModel, BertForSequenceClassification, BertTokenizer
from paddlenlp.transformers import ErnieModel, ErnieTokenizer, ErnieForSequenceClassification
from paddlenlp.data import Stack, Tuple, Pad
from paddlenlp.transformers import LinearDecayWithWarmup
from utils import *

# 加载数据集
train_ds, dev_ds, test_ds = load_dataset('chnsenticorp', splits=['train', 'dev', 'test'])

# 设置模型名称
model_name = "bert-wwm-chinese"

# 初始化模型和分词器
def init_model():
    # 使用AutoModelForSequenceClassification和AutoTokenizer加载预训练模型和分词器
    model = AutoModelForSequenceClassification.from_pretrained(model_name, num_classes=len(train_ds.label_list))
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    return tokenizer, model

def init_bert():
    # 使用BertForSequenceClassification和BertTokenizer加载预训练模型和分词器
    model = BertForSequenceClassification.from_pretrained(model_name)
    tokenizer = BertTokenizer.from_pretrained(model_name)
    return tokenizer, model

def init_ernie():
    # 使用ErnieForSequenceClassification和ErnieTokenizer加载预训练模型和分词器
    mod_name = "ernie-tiny"
    label_list = train_ds.label_list
    model = ErnieForSequenceClassification.from_pretrained(mod_name, num_classes=len(label_list))
    tokenizer = ErnieTokenizer.from_pretrained(mod_name)
    return tokenizer, model

# 数据转换函数,将文本数据转换为模型可接受的格式
def convert_example(example, tokenizer):
    encoded_inputs = tokenizer(text=example["text"], max_seq_len=512, pad_to_max_seq_len=True)
    return tuple([np.array(x, dtype="int64") for x in [
            encoded_inputs["input_ids"], encoded_inputs["token_type_ids"], [example["label"]]]])

# 主函数
def main():
    # 模型保存路径
    mod_name = '/home/aistudio/checkpoint'
    # 每个批次的样本数
    batch_size = 32
    # 最大序列长度
    max_seq_length = 128
    
    # 初始化模型和分词器
    tokenizer, model = init_model()
    
    # 将训练数据集转换为模型可接受的格式
    train_ds = train_ds.map(partial(convert_example, tokenizer=tokenizer))

    # 创建训练数据加载器
    batch_sampler = paddle.io.BatchSampler(dataset=train_ds, batch_size=8, shuffle=True)
    train_data_loader = paddle.io.DataLoader(dataset=train_ds, batch_sampler=batch_sampler, return_list=True)
    
    # 创建数据加载器
    trans_func = partial(convert_example, tokenizer=tokenizer, max_seq_length=max_seq_length)
    batchify_fn = lambda samples, fn = Tuple(
        [Pad(axis=0, pad_val=tokenizer.pad_token_id),
        Pad(axis=0, pad_val=tokenizer.pad_token_type_id),
        Stack(dtype="int64")]  # label
    ): [data for data in fn(samples)]
    train_data_loader = create_dataloader(train_ds, mode='train', batch_size=batch_size, batchify_fn=batchify_fn, trans_fn=trans_func)
    dev_data_loader = create_dataloader(dev_ds, mode='dev', batch_size=batch_size, batchify_fn=batchify_fn, trans_fn=trans_func)

    # 定义学习率相关参数
    learning_rate = 5e-5
    epochs = 1  
    warmup_proportion = 0.1
    weight_decay = 0.01

    num_training_steps = len(train_data_loader) * epochs
    lr_scheduler = LinearDecayWithWarmup(learning_rate, num_training_steps, warmup_proportion)
    
    # 定义优化器
    optimizer = paddle.optimizer.AdamW(learning_rate=lr_scheduler, parameters=model.parameters(), weight_decay=weight_decay, apply_decay_param_fun=lambda x: x in [p.name for n, p in model.named_parameters() if not any(nd in n for nd in ["bias", "norm"])])

    # 定义损失函数和评价指标
    criterion = paddle.nn.loss.CrossEntropyLoss()
    metric = paddle.metric.Accuracy()
    global_step = 0

    # 训练循环
    for epoch in range(1, epochs + 1):
        for step, batch in enumerate(train_data_loader, start=1):
            input_ids, segment_ids, labels = batch
            logits = model(input_ids, segment_ids)
            loss = criterion(logits, labels)
            probs = F.softmax(logits, axis=1)
            correct = metric.compute(probs, labels)
            metric.update(correct)
            acc = metric.accumulate()

            global_step += 1
            if global_step % 10 == 0:
                print("global step %d, epoch: %d, batch: %d, loss: %.5f, acc: %.5f" % (global_step, epoch, step, loss, acc))
            loss.backward()
            optimizer.step()
            lr_scheduler.step()
            optimizer.clear_grad()

    # 保存模型和分词器
    model.save_pretrained('/home/aistudio/checkpoint')
    tokenizer.save_pretrained('/home/aistudio/checkpoint')

main()

六、chatGLM3本地部署

关于模型的部署请参考以下chatGLM3的本地部署,由于篇幅过长不在此展示。

本地部署:windows操作系统本地部署开源语言模型ChatGLM3-6b,超详细-CSDN博客

七、模型微调

模型微调的成本

概述

模型微调主要分为全参数微调和部分参数微调,全参数微调的成本是比较高的,因为它和预训练的成本是相同的。

部分参数微调就是对模型去加一些参数,或者是对某一些层进行训练,这样就极大的节省了训练的成本。

全参数微调的成本  1B=16G+batch size  约=30G GPU

peft-lora部分参数微调    1B=5G的GPU + 1GB的CPU

peft+lora部分参数微调 (DeepSpeed+CPUOffloading)1B = 3G的GPU + 6GB的CPU

使用Offloading是有代价的,它要不断的把显存换入换出会降低训练速度

全量微调计算资源

GPU内存占用:对于一个1B参数的模型,全参数微调可能需要约30GB的GPU显存(假设每个参数占用16字节,加上batch size所需的空间,总约为1B×16字节+batch size)。这意味着需要配备高端GPU,如NVIDIA A100(40GB或更大显存)进行单卡训练,或使用多卡并行以满足内存需求。

CPU资源:全参数微调也会占用一定的CPU资源,用于数据预处理、模型加载、优化器操作等,但相对于GPU资源,CPU成本通常较低。

全量微调时间成本

时间成本: 根据模型大小、数据集规模、学习率设定等因素,全参数微调可能需要数小时至数天不等。对于1B参数的模型,假设使用合适的硬件配置和优化算法,微调时间可能在几天左右。

部分参数微调的成本

部分参数微调通过仅更新模型的部分参数或添加少量新参数来降低微调成本,例如使用PEFT-Lora等技术。

计算资源:

PEFT-Lora部分参数微调:对于1B参数的模型,可能仅需约5GB的GPU显存和1GB的CPU内存。这大大降低了对高端GPU的依赖,可能使用中低端GPU就能完成微调。

PEFT+Lora+Offloading(DeepSpeed+CPUOffloading):进一步优化后,可能只需3GB GPU显存和6GB CPU内存。然而,使用Offloading技术虽然能减少GPU显存压力,但不断将数据在GPU与CPU之间交换会导致一定的性能损失,即训练速度可能会下降。

模型微调的PEFT(Parameter-Efficient Fine-Tuning)

概念

PEFT,即Parameter-Efficient Fine-Tuning,是一种旨在降低大型预训练模型微调成本的技术方法。它的核心理念是通过仅更新模型中的一小部分参数或引入额外的轻量级模块,而不是对整个模型的所有参数进行重新训练,来实现对预训练模型在新任务上的快速适应和性能提升。这种参数高效微调方式特别适合在计算资源有限的环境中应用,使得研究人员和开发人员能够在保持模型性能的前提下,更经济、快捷地利用预训练模型解决特定任务。

PEFT 里的微调策略
Adapter-like

Adapter是一种插件式的结构,它在预训练模型的内部层间插入小型可训练模块,这些模块通常具有较低的维度(相比模型原层的通道数)以保持轻量化。Adapter的设计理念是,尽管预训练模型的主体权重保持固定,但通过在关键位置添加这些可学习的组件,可以针对性地调整模型的内部表示,使其适应新的下游任务。

Adapter-like方法的具体工作原理通常包括以下几个方面:

结构设计:Adapter模块通常采用 bottleneck 结构,包含一个降维(down-projection)层、一个非线性激活函数(如ReLU)和一个升维(up-projection)层。输入到Adapter的特征经过降维处理后,进行非线性变换,然后再通过升维层恢复到原来的维度,以便无缝融入到预训练模型的原有架构中。

插入位置:Adapter可以插入到预训练模型的不同层次,如Transformer模型的每一层注意力模块之后或卷积神经网络的某些中间层。选择合适的插入位置有助于捕获不同层次的语义信息,以适应任务的特定需求。

独立训练:在微调阶段,只有Adapter模块的参数会被更新,而预训练模型的其他所有权重保持不变。这样,即使针对多个任务进行微调,也可以共用同一份预训练模型,只需为每个任务训练一组独立的Adapter即可,大大减少了存储和计算开销。

组合与堆叠:多个Adapter可以组合使用,或者在同一层中堆叠多个Adapter,以增加模型的表达能力和对复杂任务的适应性。不同Adapter可以专注于学习任务相关的不同子空间特征。

Soft Prompts

Soft Prompts是一种在模型输入端添加可学习向量(prompt tokens)的微调策略。这些向量类似于自然语言处理中的人工文本提示,但它们不是固定的文本字符串,而是由一系列可训练的浮点数值构成的向量。Soft Prompts旨在引导模型在处理新任务时模拟类似预训练过程中的自我监督学习机制,无需直接修改模型内部参数。

Soft Prompts的工作机制主要包括:

初始化与插入:Soft Prompts通常被初始化为随机值,并在模型输入序列的开始(或结束)处插入。对于Transformer架构的模型,这些向量与词汇表中的token一起经过embedding层,形成新的输入向量序列。

任务适应:在微调阶段,只有Soft Prompts的参数会被更新,模型其余部分保持不动。通过调整这些prompt tokens的值,模型可以学会如何以特定的方式“解读”输入,从而在不改变预训练权重的前提下,适应新任务的特定模式或语境。

灵活性与可解释性:Soft Prompts为模型赋予了一定的灵活性,因为它们可以动态调整以适应不同任务或样本。此外,通过分析学习到的prompt向量,有时可以获取关于模型如何理解任务的直观线索,提高模型的可解释性。

多任务与零样本学习:同一种预训练模型可以通过配置不同的Soft Prompts来处理多个任务,甚至在某些情况下,无需额外微调就可在未见过的任务上表现出一定能力,这在零样本或少样本学习场景中具有吸引力。

PEFT技术

PEFT涵盖了一系列具体的微调策略和方法,这些策略各有特点,但都遵循参数效率原则。以下是一些常见的PEFT技术:

BitFit:仅微调模型中的bias terms(偏置项),这些参数通常占总参数量的比例很小,但对模型输出影响较大。通过更新偏置项,能在保留大部分预训练权重不变的情况下调整模型行为。

Prefix Tuning和Prompt Tuning:这两种方法都涉及在模型输入中插入可学习的向量(前缀或提示),这些向量作为额外的输入引导模型在特定任务上的表现。微调时,仅优化这些前缀或提示向量,而模型主体保持不变。

P-Tuning:引入参数化转换矩阵,通过调整这些矩阵来改变预训练模型的权重分布,而非直接更新模型参数。这种方法可以看作是对模型权重空间的一种间接微调。

LoRA (Learned Representations for Finetuning):在模型内部添加低秩矩阵(通常为稀疏的),这些矩阵与原有权重相乘以调整模型的中间表示。微调时,仅更新这些低秩矩阵,而保持预训练模型的大部分权重不变。LoRA能够显著减少微调参数量,同时保持良好的性能。

Prompt Tuning实现方式以及过程

import torch
from torch import nn
import math

# 定义PromptEmbedding类,继承自nn.Module
class PromptEmbedding(nn.Module):
    """
    一个自定义的PyTorch模块,实现参数高效微调中的Prompt Embeddings。

    该模块提供一个虚拟令牌(prompt)的嵌入层,在微调过程中使用。这些prompt可以随机初始化,也可以使用预训练tokenizer获取的文本嵌入进行初始化。

    参数:
        config (Config): 包含与prompt微调相关参数的配置对象,如虚拟令牌数量、令牌维度、初始化方法、tokenizer信息等。
        word_embeddings (nn.Module): 预训练的词嵌入模块(如来自Transformer模型)。
        tokenizer (Optional[AutoTokenizer], 默认=None): 预训练的tokenizer实例。如果不提供,将根据`config.tokenizer_name_or_path`值自动实例化。
    """

    def __init__(self, config, word_embeddings, tokenizer=None):
        super(PromptEmbedding, self).__init__()

        # 计算所需的总虚拟令牌数,考虑每个子模块的虚拟令牌数以及总的transformer子模块数(如Seq2Seq模型中的编码器和解码器层数)。
        total_virtual_tokens = config.num_virturl_tokens * config.num_transformer_submodules

        # 初始化prompt嵌入层,指定总虚拟令牌数和令牌维度。
        self.embedding = nn.Embedding(total_virtual_tokens, config.token_dim)

        # 如果初始化方法设置为'text',则使用提供的tokenizer从给定文本字符串生成初始令牌ID。
        if config.prompt_tuning_init == 'text':

            # 确保有可用的tokenizer;如果没有提供,根据指定的tokenizer名称/路径实例化一个。
            if tokenizer is None:
                tokenizer = AutoTokenizer.from_pretrained(config.tokenizer_name_or_path)

            # 用于prompt嵌入初始化的初始文本。
            init_text = config.prompt_tuning_init_text

            # 使用提供的tokenizer对输入文本进行分词。
            init_token_ids = tokenizer(init_text)['input_ids']

            # 计算初始化文本中的令牌数。
            num_text_tokens = len(init_token_ids)

            # 如果文本令牌数大于总虚拟令牌数,截取前total_virtual_tokens个;否则,如果小于总虚拟令牌数,
            # 则按需复制以填满总虚拟令牌数;最后,确保不超过总虚拟令牌数。
            if num_text_tokens > total_virtual_tokens:
                init_token_ids = init_token_ids[:total_virtual_tokens]
            elif num_text_tokens < total_virtual_tokens:
                num_reps = math.ceil(total_virtual_tokens/ num_text_tokens)
                init_token_ids = init_token_ids * num_reps
            init_token_ids = init_token_ids[:total_virtual_tokens]

            # 使用预训练的词嵌入模块计算给定初始令牌ID的词嵌入,并将其转换为float32类型。
            word_embeds = word_embeddings(torch.LongTensor(init_token_ids)).detach().clone()
            word_embeds = word_embeds.to(torch.float32)

            # 将计算得到的词嵌入赋值给prompt嵌入层的weight参数。
            self.embedding.weight = nn.Parameter(word_embeds)

    def forward(self, indices):
        """
        前向传播函数,接收prompt索引作为输入,返回对应的prompt嵌入。

        参数:
            indices (torch.Tensor): 形状为(batch_size, prompt_length)的prompt索引张量。

        返回:
            prompt_embs (torch.Tensor): 形状为(batch_size, prompt_length, token_dim)的prompt嵌入张量。
        """
        # 提取与输入indices对应的prompt嵌入。
        # 输入indices形状如[0, 1, 2, 3, 4],表示一个批次内的prompt索引。
        prompt_embs = self.embedding(indices)
        # 输出prompt_embs形状为(batch_size, prompt_length, token_dim),即每个prompt包含token_dim维的嵌入向量。
        return prompt_embs

Prefix Tuning实现方式以及过程

import torch
from torch import nn

# 定义PrefixEncoder类,继承自nn.Module,用于处理前缀(prefix)编码
class PrefixEncoder(nn.Module):
    def __init__(self, config):
        """
        构造函数,初始化PrefixEncoder类的实例。

        参数:
            config (Config): 包含模型配置信息的对象,如是否进行前缀投影、令牌维度、层数、隐藏尺寸、虚拟令牌数量等。
        """
        super(PrefixEncoder, self).__init__()  # 调用父类nn.Module的构造函数

        self.prefix_projection = config.prefix_projection  # 是否进行前缀投影的布尔标志
        token_dim = config.token_dim  # 令牌(token)维度
        num_layers = config.num_layers  # Transformer模型的层数
        encoder_hidden_size = config.encoder_hidden_size  # 编码器的隐藏尺寸
        num_virtual_tokens = config.num_virtual_tokens  # 虚拟令牌数量

        # 如果配置要求进行前缀投影且当前不在推理模式下,则构建前缀投影路径
        if self.prefix_projection and not config.inference_mode:
            # 创建一个嵌入层,用于将虚拟令牌映射到token_dim维的向量空间
            self.embedding = nn.Embedding(num_virtual_tokens, token_dim)

            # 定义前缀投影的序列化模型结构,包括线性层、激活函数和最终的线性层
            self.transform = nn.Sequential(
                nn.Linear(token_dim, encoder_hidden_size),  # 将token_dim维映射到encoder_hidden_size维
                nn.Tanh(),  # 应用Tanh激活函数
                # 为Transformer的关键值对(k, v)生成输出
                nn.Linear(encoder_hidden_size, num_layers * 2 * token_dim)  # 将encoder_hidden_size维映射到num_layers * 2 * token_dim维
            )

        # 如果不进行前缀投影,直接创建一个嵌入层,将虚拟令牌映射到num_layers * 2 * token_dim维的向量空间
        else:
            self.embedding = nn.Embedding(num_virtual_tokens,
                num_layers * 2 * token_dim
            )

    def forward(self, prefix):
        """
        前向传播函数,接受前缀(prefix)作为输入,返回经过编码的前缀表示(past_key_values)。

        参数:
            prefix (torch.Tensor): 形状为(batch_size, num_virtual_tokens)的张量,表示每个样本的虚拟前缀令牌。

        返回:
            past_key_values (torch.Tensor): 经过编码的前缀表示,形状为(batch_size, num_virtual_tokens, num_layers * 2 * token_dim)。
        """
        # 如果需要进行前缀投影
        if self.prefix_projection:
            # 使用嵌入层将前缀令牌映射到token_dim维向量
            prefix_tokens = self.embedding(prefix)

            # 通过定义好的前缀投影路径对映射后的前缀令牌进行变换,得到关键值对(k, v)
            past_key_values = self.transform(prefix_tokens)

        # 如果不需要进行前缀投影,直接使用嵌入层得到关键值对(k, v)
        else:
            past_key_values = self.embedding(prefix)

        return past_key_values  # 返回编码后的前缀表示

LoRA  实现方式以及过程

import torch
from torch import nn
import torch.nn.functional as F

# 定义LoraModel类,继承自nn.Module,封装了一个包含Lora适配器的模型
class LoraModel(nn.Module):
    def __init__(self, config, model):
        """
        构造函数,初始化LoraModel类的实例。

        参数:
            config (Config): 包含Lora适配器相关配置信息的对象。
            model (nn.Module): 待适配的基础模型,可以是任何继承自nn.Module的神经网络模型。
        """
        super(LoraModel, self).__init__()
        self.peft_config = config
        self.model = model
        self._find_and_replace()

    def _find_and_replace(self):
        """
        该方法在内部实现查找基础模型中的线性层,并将其替换为Lora适配器支持的线性层(如Linear或MergedLinear)。
        由于该方法的实现未给出,此处仅为占位符。

        注意:实际应用时,应根据具体需求实现该方法,以完成对模型线性层的替换。
        """
        # embedding
        pass

def mark_only_lora_as_trainable():
    """
    该函数的作用是设置模型中仅Lora适配器的权重为可训练状态,其余部分权重设为不可训练。
    由于该函数的实现未给出,此处仅为占位符。

    注意:实际应用时,应根据具体需求实现该函数,以控制模型中各部分权重的训练状态。
    """
    pass

# 定义LoraLayer基类,用于存储与Lora适配器相关的通用属性和方法
class LoraLayer:
    def __init__(self,
            r: int,
            # W + alpha * w_delta
            lora_alpha: int,
            lora_dropout: float,
            merge_weights: bool
    ):
        """
        初始化LoraLayer类的实例。

        参数:
            r (int): Lora适配器的秩(rank)。
            lora_alpha (int): 控制w_delta(Lora适配器权重)与原始权重W之间加权系数的值。
            lora_dropout (float): 用于Lora适配器中间计算的dropout比例。
            merge_weights (bool): 是否在模型评估时合并Lora适配器权重与原始权重。
        """
        self.r = r
        self.lora_alpha = lora_alpha
        if lora_dropout > 0.0:
            self.lora_dropout = nn.Dropout(p=lora_dropout)
        else:
            self.lora_dropout = lambda x: x  # 如果dropout为0,使用恒等函数替代Dropout层
        # 当前状态标记
        self.merged = False
        self.merge_weights = merge_weights
        # Lora适配器相关标记
        self.disable_adapters = False

def transpose(weight):
    """
    简单的转置函数,将输入张量的最后两个维度交换位置。

    参数:
        weight (torch.Tensor): 需要转置的张量。

    返回:
        torch.Tensor: 转置后的张量。
    """
    return weight.T

# 定义LoraLinear类,继承自nn.Linear和LoraLayer,实现了带有Lora适配器的线性层
class Linear(nn.Linear, LoraLayer):
    def __init__(self,
            in_features: int,
            out_features: int,
            r: int = 0,  # 默认秩为0,即不启用Lora适配器
            lora_alpha: int = 1,
            lora_dropout: float = 0.0,
            merge_weights: bool = True,
            **kwargs
    ):
        """
        初始化LoraLinear类的实例。

        参数:
            in_features (int): 输入特征维度。
            out_features (int): 输出特征维度。
            r (int, optional): Lora适配器的秩(rank)。默认为0。
            lora_alpha (int, optional): 控制w_delta(Lora适配器权重)与原始权重W之间加权系数的值。默认为1。
            lora_dropout (float, optional): 用于Lora适配器中间计算的dropout比例。默认为0.0。
            merge_weights (bool, optional): 是否在模型评估时合并Lora适配器权重与原始权重。默认为True。
            **kwargs: 其他传递给nn.Linear构造函数的参数。
        """
        nn.Linear.__init__(self, in_features, out_features, **kwargs)
        LoraLayer.__init__(self, r, lora_alpha, lora_dropout, merge_weights)
        if r > 0:
            # A*B = W
            # [r, in]
            self.lora_A = nn.Linear(in_features, r, bias=False)
            # [out, r]
            self.lora_B = nn.Linear(r, out_features, bias=False)
            self.scaling = self.lora_alpha / self.r
            self.weight.requires_grad = False  # 设置原始权重不可训练

    def train(self, mode: bool = True):
        """
        设置模型训练/评估模式。

        参数:
            mode (bool, optional): 是否为训练模式。默认为True。

        注意:此方法会同时调整Lora适配器子模块(lora_A和lora_B)的训练模式。
        """
        nn.Linear.train(self, mode)
        self.lora_A.train(mode)
        self.lora_B.train(mode)
        if not mode and self.merge_weights and not self.merged:
            if self.r > 0:
                # 使用矩阵乘法和转置操作合并Lora适配器权重与原始权重
                self.weight.data += (
                    transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
                )
            self.merged = True
        elif self.merge_weights and self.merged:
            if self.r > 0:
                self.weight.data -= (
                    transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
                )
            self.merged = False

    def eval(self):
        """
        设置模型为评估模式。

        注意:此方法会同时调整Lora适配器子模块(lora_A和lora_B)的评估模式。
        """
        nn.Linear.eval(self)
        self.lora_A.eval()
        self.lora_B.eval()

    def forward(self, x: torch.Tensor):
        """
        前向传播函数,接受输入张量并返回经过Lora适配器增强的线性变换结果。

        参数:
            x (torch.Tensor): 形状为(batch_size, in_features)的输入张量。

        返回:
            torch.Tensor: 经过线性变换的输出张量,形状为(batch_size, out_features)。
        """
        if self.disable_adapters:
            if self.r > 0 and self.merged:
                self.weight.data -= (
                    transpose(self.lora_B.weight @ self.lora_A.weight) * self.scaling
                )
                self.merged = False
            return F.linear(x, transpose(self.weight), bias=self.bias)
        elif self.r > 0 and not self.merged:
            # W + w_delta
            # xW + x*w_delta
            result = F.linear(x, transpose(self.weight), bias=self.bias)
            if self.r > 0:
                result += self.lora_B(self.lora_A(self.lora_dropout(x))) * self.scaling
            return result
        else:
            return F.linear(x, transpose(self.weight), bias=self.bias)

from typing import List
class MergedLinear(nn.Linear, LoraLayer):
    """
    定义MergedLinear类,继承自nn.Linear和LoraLayer,实现了一个具有Lora适配器且支持多个分支的线性层。

    参数:
        in_features (int): 输入特征维度。
        out_features (int): 输出特征维度。
        r (int, default=0): Lora适配器的秩(rank)。
        lora_alpha (int, default=1): 控制w_delta(Lora适配器权重)与原始权重W之间加权系数的值。
        lora_dropout (float, default=0.0): 用于Lora适配器中间计算的dropout比例。
        enable_lora (List[bool], default=[False]): 指定哪些输出分支启用Lora适配器。
        merge_weights (bool, default=True): 是否在模型评估时合并Lora适配器权重与原始权重。
        **kwargs: 传递给nn.Linear构造函数的其他参数。
    """

    def __init__(self,
            in_features: int,
            out_features: int,
            r: int = 0,  # 默认秩为0,即不启用Lora适配器
            lora_alpha: int = 1,
            lora_dropout: float = 0.0,
            enable_lora: List[bool] = [False],
            merge_weights: bool = True,
            **kwargs):

        nn.Linear.__init__(self, in_features, out_features, **kwargs)  # 初始化nn.Linear基类
        LoraLayer.__init__(self, r, lora_alpha, lora_dropout, merge_weights)  # 初始化LoraLayer基类

        self.enable_lora = enable_lora  # 存储哪些输出分支启用Lora适配器

        # 如果r大于0且存在至少一个启用Lora适配器的分支
        if r > 0 and any(enable_lora):
            # 初始化Lora适配器A,将输入映射到(r * sum(enable_lora))维空间,无偏置项
            self.lora_A = nn.Linear(in_features, r * sum(enable_lora), bias=False)

            # 初始化Lora适配器B,使用1D卷积实现多分支融合,输入为(r * sum(enable_lora))维,输出为(out_features // len(enable_lora) * sum(enable_lora))维
            self.lora_B = nn.Conv1d(
                r * sum(enable_lora),
                out_features // len(enable_lora) * sum(enable_lora),
                kernel_size=1,
                groups=2,  # 按照enable_lora列表中的分支划分组
                bias=False
            )

            self.scaling = lora_alpha / r  # 计算加权系数的缩放因子

            # 将原始权重设置为不可训练
            self.weight.requires_grad = False

            # 初始化一个布尔型张量lora_ind,用于标记哪些输出元素属于启用Lora适配器的分支
            self.lora_ind = self.weight.new_zeros((out_features),
                dtype=torch.bool).view(len(enable_lora), -1)
            # 根据enable_lora列表,将对应分支的输出元素标记为True
            self.lora_ind[enable_lora, :] = True
            # 将lora_ind展平为一维
            self.lora_ind = self.lora_ind.view(-1)

    def zero_pad(self, x):
        """
        对输入张量x进行零填充,使其与启用Lora适配器的输出分支长度相匹配。

        参数:
            x (torch.Tensor): 需要进行零填充的张量,形状为(batch_size, seq_len, out/3 * sum(enable_lora))。

        返回:
            torch.Tensor: 经过零填充后的新张量,形状为(batch_size, seq_len, out)。
        """
        # 创建一个全零张量,形状为(batch_size, seq_len, out),用于存储最终结果
        result = x.new_zeros((*x.shape[:-1], self.out_features))

        # 将结果张量展平为形状为(batch_size * seq_len, out)
        result = result.view(-1, self.out_features)

        # 将输入张量x的启用Lora适配器分支部分复制到结果张量对应位置
        result[: self.lora_ind] = x.reshape(-1,
            self.out_features // len(self.enable_lora) * sum(self.enable_lora))

        # 将结果张量恢复为原始形状(batch_size, seq_len, out)
        return result.view((*x.shape[:-1], self.out_features))

    def train(self, mode: bool = True):
        """
        设置模型训练/评估模式。

        参数:
            mode (bool, optional): 是否为训练模式。默认为True。

        注意:此方法会同时调整Lora适配器子模块(lora_A和lora_B)的训练模式。
        """
        nn.Linear.train(self, mode)  # 设置nn.Linear基类的训练模式
        self.lora_A.train(mode)  # 设置Lora适配器A的训练模式
        self.lora_B.train(mode)  # 设置Lora适配器B的训练模式

        # 如果处于非训练模式且需要合并权重且尚未合并
        if not mode and self.merge_weights and not self.merged:
            if self.r > 0 and any(self.enable_lora):
                # 计算Lora适配器权重与原始权重之间的差异(delta_w),并将其应用到原始权重上
                delta_w = F.conv1d(
                    self.lora_A.weight.data.unsqueeze(0),  # [1, r*sum(enable_lora), in]
                    self.lora_B.weight.data.unsqueeze(-1),  # [out/3*2, r*sum(enable_lora), 1]
                    groups=sum(self.enable_lora)
                ).squeeze(0)  # [in, out/3*2]
                self.weight.data += self.zero_pad(transpose(delta_w * self.scaling))  # 更新原始权重

                self.merged = True  # 标记已合并权重

        # 如果处于非训练模式且需要合并权重且已合并
        elif self.merge_weights and self.merged:
            if self.r > 0 and any(self.enable_lora):
                # 计算Lora适配器权重与原始权重之间的差异(delta_w),并将其从原始权重中减去
                delta_w = F.conv1d(
                    self.lora_A.weight.data.unsqueeze(0),  # [1, r*sum(enable_lora), in]
                    self.lora_B.weight.data.unsqueeze(-1),  # [out/3*2, r*sum(enable_lora), 1]
                    groups=sum(self.enable_lora)
                ).squeeze(0)  # [in, out/3*2]
                self.weight.data -= self.zero_pad(transpose(delta_w * self.scaling))  # 更新原始权重

                self.merged = False  # 标记未合并权重

    def eval(self):
        """
        设置模型为评估模式。

        注意:此方法会同时调整Lora适配器子模块(lora_A和lora_B)的评估模式。
        """
        nn.Linear.eval(self)  # 设置nn.Linear基类的评估模式
        self.lora_A.eval()  # 设置Lora适配器A的评估模式
        self.lora_B.eval()  # 设置Lora适配器B的评估模式

    def forward(self, x: torch.Tensor):
        """
        前向传播函数,接受输入张量并返回经过Lora适配器增强的多分支线性变换结果。

        参数:
            x (torch.Tensor): 形状为(batch_size, in_features)的输入张量。

        返回:
            torch.Tensor: 经过分支线性变换的输出张量,形状为(batch_size, out_features)。
        """
        if self.disable_adapters:
            if self.r > 0 and self.merged and any(self.enable_lora):
                delta_w = F.conv1d(
                    self.lora_A.weight.data.unsqueeze(0),  # [1, r*sum(enable_lora), in]
                    self.lora_B.weight.data.unsqueeze(-1),  # [out/3*2, r*sum(enable_lora), 1]
                    groups=sum(self.enable_lora)
                ).squeeze(0)  # [in, out/3*2]
                self.weight.data -= self.zero_pad(transpose(delta_w * self.scaling))  # 更新原始权重
                self.merged = False  # 标记未合并权重
            return F.linear(x, transpose(self.weight), bias=self.bias)  # 使用原始权重进行线性变换

        elif self.merged:
            return F.linear(x, transpose(self.weight), bias=self.bias)  # 使用原始权重进行线性变换

        else:
            result = F.linear(x, transpose(self.weight), bias=self.bias)  # 使用原始权重进行线性变换

            if self.r > 0:
                # 将输入x通过Lora适配器A进行变换,得到形状为(seq_len, r)的

  • 26
    点赞
  • 25
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值