跳过正文
  1. 日常记录、技术札记与转载收藏。/

LLaMA 2 动手实现

目录

认识数据集
#

1、预训练数据集 和 SFT 数据集
#

在进行 LLaMA 2 模型的数据集准备时,需要明确区分预训练数据集(pretraining dataset)和SFT 数据集(supervised fine-tuning dataset),因为两者的来源、形式和用途都有明显差异。

预训练数据集

  • 特点:通常用于模型的基础语言理解能力训练,数据量大、覆盖面广,但不要求格式严格。

  • 来源:主要是从网络上爬取的海量文本,包括新闻、论坛帖子、维基百科、技术文档、小说、学术论文等。

  • 数据形式:文本形式为主,不一定包含明确的对话或问答结构,例如一篇文章、一段新闻报道或一本电子书的章节。

  • 举例

    • 从维基百科抓取的“自然语言处理”条目文本

    • GitHub 上的开源代码及注释

    • Reddit 或知乎论坛的讨论帖子(去掉敏感信息)

SFT 数据集(对话式微调数据集)

  • 特点:用于指导模型学习具体任务或对话能力,需要有明确的输入输出(instruction-response)关系。

  • 来源:通常通过人工标注、半自动生成或整理已有对话数据集获得。

  • 数据形式:以对话或问答形式呈现,每条数据通常包含“用户输入 → 模型期望输出”,有时还会包含上下文历史。

  • 举例

    • 人工编写的问答对:用户问“LLaMA 2 是什么?” → 输出“LLaMA 2 是一个由 Meta 发布的开源大语言模型。”

    • 已有对话数据集如 Alpaca、ShareGPT 数据

    • 客服对话记录(脱敏处理后)

问题来了,为什么要这么做呢?

两者的目标不一样,预训练的目标是让模型掌握语言的基本规律、知识储备和常见模式。SFT 阶段让模型学会按照指令完成具体任务,或者进行有用的对话。

总的可以概述为:

预训练 = 打语言和知识基础,像“打地基”,覆盖面大但不精细。

SFT = 教模型做事情,像“装修和布置房子”,精细但依赖基础。

2、我们使用的
#

我们就不再从0开始,去爬数据,洗数据了,有机会我们可以单独开一章怎么做SFT数据,因为这个比较有意思,不需要从头开始训练,省点钱,只要去制造一些数据,这个时候是不是会发现盯着微信,qq里面好几个G的聊天数据发呆,没错。这个就是天然的,高质量的数据,是非常非常符合我们每个人具有特色的说话方式的数据。幻想一下,我们和一个完全拥有自己说话口吻的方式相互对话,那可太棒了,相当于是有一个自己的聊天分身,同时和一万个人聊天都是毫无压力的。

好了,回归正题:

数据来源于这个项目:https://github.com/mobvoi/seq-monkey-data?tab=readme-ov-file

Image

下载完成后:

整体数据大小有20G多一点,还是有点大的,2块3090 24G要跑3天。所以我打算抽取2G左右的数据,也就是1/10的数据量级。所有的我们自己训练的数据都会上传到GitHub仓库。我们仅仅是为了跑通模型,不追求效果,所以只要部分数据即可。

数据抽取
#

对应这种几个G的数据,直接肯定是打不开的,我们写几行简单的代码,查看前面几行就行。看看数据到底是长什么样的,同时,在作者声明中也有说明:{“text”: “<文档>”}

代码在同级目录的Code/preview_jsonl.py

import json

# 我们定义一个文件路径
file_path = "/root/autodl-tmp/Dataset/mobvoi_seq_monkey_general_open_corpus.jsonl"

k = 1000

with open(file_path, "r", encoding="utf-8") as f:
    for i, line in enumerate(f):
        if i >= k:
            break
        try:
            data = json.loads(line.strip())
            print(data)
        except json.JSONDecodeError:
            print("解析失败")

可以看见,具体里面是这样的内容:

Image
Image

他们都是符合这样{“text”: “<文档>"}的格式。

{'text': '在查处虚开增值税专用发票案件中,常常涉及进项留抵税额和税款损失的认定和处理。在计算税款损失时,要不要将进项留抵税额包括在内?\n对此,实务中存在意见分歧。\n有人主张归并,即计算税款损失时包括进项留抵税额;\n有人主张剥离,即计算税款损失时剔除进项留抵税额。分析这个问题,需要确定进项留抵税额与税款损失之间是什么关系。\n理清这二者之间的关系,首先需要了解增值税的概念和其抵扣机制。增值税是以商品(货物、服务等)在流转过程中产生的增值额作为计税依据而征收的一种流转税。为避免重复征税,在增值税中存在抵扣链条机制。\n一般而言,交易上游企业缴纳的税额,交易下游企业可以对相应的税额进行抵扣。\n对增值税一般纳税人来说,其购进货物、服务等取得增值税专用发票,发票上的税额是进项税额。\n其出售货物、服务等,向购买方开具增值税专用发票,发票的税额是销项税额。\n一般情况下,销项税额减去进项税额的金额是应纳税额,企业根据应纳税额按期申报纳税。\n其次需要了解进项留抵税额的概念及产生原因。\n在计算销项税额和进项税额的差额时,有时会出现负数,即当期进项税额大于当期销项税额。这个差额在当期未实现抵扣,为进项留抵税额,在以后纳税人有销项税额时再进行抵扣。\n企业产生进项留抵税额的主要原因是其进项税额和销项税额时间上的不一致。\n例如,企业前期集中采购货物和服务,投资大,销项税率低于进项税率等。\n从税款抵扣的角度看,进项留抵税额只是购进的这部分进项税额参与到增值税应纳税额的计算过程中,但是其对应的进项税额抵扣还未真正实现,一般要等到其未来有相应的销项税额时,才能真正实现进项税额抵扣。\n可见,进项留抵税额处于不确定状态,能否抵扣受到很多因素影响,例如企业经营中断,没有销项税额,这时进项留抵税额就无法实现抵扣。但如果企业按照税收政策规定申请进项留抵退税,进项税额抵扣就随之实现。\n最后需要了解税款损失的概念。\n税款损失,通常是指因虚开增值税专用发票,导致国家税款被骗或者流失的金额。关于税款损失,实务中有多种表述。\n例如,北京大学法学院教授陈兴良曾谈到虚开行为本身不会造成国家税款损失,只有利用发票抵扣时才会造成国家税款损失。刘兵等编著的《虚开增值税专用发票案例司法观点和案例解析》一书中提到:“给国家税款造成损失的数额,实际上就是被骗取的国家税款在侦查终结以前无法追回的部分。”\n赵清海与王家欣合著的《增值税专用发票虚开的判定与预防》一书中提到:“司法实践中,受票方用虚开的增值税专用发票予以抵扣的税款,从而导致受票方应纳税额的减少是法院所认定的国家税款流失的金额。”\n从这些表述可见,税款损失应该是实际造成的损失,不应包括不确定的部分——进项留抵税额,进项留抵税额与税款损失之间不能直接画等号。\n综上分析,进项留抵税额,只是使国家税款处于可能被抵扣的状态,还没有真正造成国家税款流失,一般情况下应将其从税款损失中剥离,特殊条件下将其归并入税款损失。\n例如,当纳税人造假按照税收政策规定申请进项留抵税额退税后,有关税款损失将会从危险状态转化成危害结果,这时候要将有关进项留抵税额并入税款损失。\n所以,在虚开增值税专用发票案件中,一般情况下,如果以纳税人的进项税额作为税款损失的计算基数,在对其进行行政处罚或刑事处罚时,应把进项留抵税额从税款损失中剔除,但纳税人申请进项留抵退税的除外。这样处理,把处罚与危害结果相对应,体现行政处罚法的过罚相当原则和刑法的罚当其罪原则。'}
{'text': '读者在使用本《年鉴》时发现与以前本局出版、公布、或内部提供的资料有出入的,概以本《年鉴》为准。\n《年鉴》正文内容分为三大部分。第一部分为文字部分,收录了《2012年政府工作报告》以及《2011年河源市国民经济和社会发展统计公报》。第二部分为统计图,形象地反映建市以来河源市国民经济发展变化情况。第三部分为统计资料,具体分为行政区划和自然资源,综合、核算、人口,农村经济,工业,能源,交通、邮电,贸易业、物价指数,对外经济、旅游,财政、金融和保险,固定资产投资与建筑业,劳动工资,人民生活,文教、卫生和其他,河源市乡镇主要经济指标,广东省县域主要经济指标,广东省各市主要经济指标等16部分。此外,为便于读者正确理解和使用统计资料,特附主要统计指标解释、统计术语简介及统计法律法规等资料。\n《年鉴》中,本市的数据是根据我局及有关部门的统计年报整理汇编而成,由于某些专业统计制度和统计口径的变化,有些数据空缺。使用本《年鉴》时,请注意指标名称的含义、统计口径、统计范围、计算单位、可比价与现行价(当年价)等。\n《年鉴》第一部分中的有些数据为初步统计数,凡与本《年鉴》中第三部分的数据有出入的,则以第三部分的统计数据为准。\n本《年鉴》部分统计数据使用了四舍五入的进位方法,因此,可能令统计表内个别项目相加与总数略有出入。\n本《年鉴》统计表中符号使用说明:“#”表示其中主要项;“空格”表示该项统计指标数据不详或无该项数据。\n本《年鉴》的编辑出版,得到县、区及市直有关部门和单位的大力支持,在此表示感谢!本书疏漏之处敬请批评指正。\n下载说明: �本站下载的文件一律为压缩文件,请使用 WinRAR 解压。\n�PDF格式的资料请使用 Adobe Reader 浏览。\n�本站提供的一些资料是供学习研究之用,如用于商业用途,请购买正版。'}
{'text': '初中阶段是学生身心发育的一个突变期。尤其是初一学生,从小学到中学,随着环境改变,课程增多,难度加大,他们内心发生了急剧变化,产生了许多烦恼、困惑,造成较大的心理偏差,这就需要教师和家长及时给予心理指导和帮助。\n一、心理偏差的种种表现\n1、骄傲自负心理。这种心理偏差主要表现在思维敏捷、小学成绩拔尖的学生身上,特别是一些长期担任班干部、竞赛获奖、父母有权力的学生表现尤为明显。\n2.单纯求趣心理。求趣激趣,这是教学的原则之一,但是,有些初一学生过分地追求接受知识要符合自己的兴趣,还想回到幼儿园、小学时“游戏教育”和“愉快教育”中去,不能努力适应初中阶段的学习生活。\n3.自卑孤僻心理。多数来自普通工薪家庭及农村贫困地区或遭遇父母婚变的学生,往往在干部、富家子弟、有特长的同学面前感到自卑,心理压抑,行为孤僻,甚至变态的自尊,影响学习。\n4.胆怯畏惧心理。部分性格内向、胆小的学生,主要是女生,羞于用语言表达思想,沉溺于内心活动和笔头表达。内心活动不能外显,妨碍了思维素质的深入发展。\n5.浮躁马虎心理。部分活泼好动的学生,智力水平不低,但就是不能静下心学习,总是浅尝辄止,马虎应付,不愿作深入的思考,常常“半罐水响叮当”。\n6.贪图享受心理。一些家境较好的学生,行为懒散,好逸恶劳,学习上畏难怕苦,生活上讲吃讲穿。\n二、上述心理偏差的形成原因\n1.生理上的原因。初中学生处于发育高峰期,身高体重剧增,性发育开始。生理上的急剧变化使儿童意识到自己不再是孩子,“成人感”增强。但是,青年身体成熟速度存在着很大的个体差异:不同性别之间相差两年左右,同性别之间相差四年左右。因此,同是初中学生,一部分学生生理已跨入青年期,而另一部分学生可能还停留在童年期。\n2.心理上的原因。随着生理的变化,“成人感”的出现,初中学生心理产生“独立”,力求摆脱对成人的依赖,老师、家长在他们心目中的权威降低,同学之间相互影响增强。思维上发展了批判性,但由于经验的缺乏含有片面性和主观性;行为上出现“独特性”和“受暗示性”乃至“抗拒性”,即逆反心理;情绪上带有冲动性,不善于克制自己;兴趣和愿望上带有随意性、多变性、狂热性,常为了所谓讲“义气”而庇护同伴,或为同伴打抱不平;感情上具有“闭锁性”,而对于艰苦的学习活动特别重要的意志品质,则还处在比较软弱的状态。\n3.环境的原因。心理学认为,个体的生物遗传因素规定了发展的潜在可能范围,而个体环境教育则确定他在此可能范围内的现实水平。环境条件有利与否对个体发展的现实水平起了决定性作用。\n①家庭。社会的信仰、观念等社会化目标都是首先通过父母的过渡,以高度个性化了的、有选择的形式传递给儿童的。父母本身的个性特征、社会地位、教育水平、宗教信仰、价值标准等等都强烈地影响他们的后代。父母的教养方式、家庭结构、物质条件、人际环境、文化和情绪氛围,都在很大程度上影响着学生。\n②学校。学校不仅是对学生传授文化科学知识,进行政治思想教育的社会基本教育单元,还是促进学生良好品格形成和发展的重要场所。学生在学校里形成良好的品格,才能顺利走向社会,适应社会生活。反之,则会发生各种问题。而现在的应试教育制度,像紧箍咒一样,时时冲击着素质教育,教师以升学率论质量给待遇,使一些教师对成绩好的学生倍加宠爱,对成绩差的学生则百般呵斥。更有少数教师将腐朽庸俗的人际关系引入师生和家长的关系,身教言传,污染了学生心灵;让孩子过早成人化、世故化。\n③社会。社会上各种腐朽思想沉渣泛起,对学生负面影响很大。影视传媒、流失少年、勒索等等,浸染着学生稚嫩的心灵;电子游戏机、卡拉OK厅等,又使我们的孩子面临着极强的诱惑,意志薄弱者稍不留意,便坠入其间。\n三、纠正初中学生的心理偏差的对策\n为了纠正初中学生的心理偏差,我们必须对教育环境影响予以高度重视。在现有环境中,我们应做到:\n1.坚持以德、智、体、美、劳全面的教育方针为指导思想进行教育管理,坚持“要成才先成人”的教育思想。\n2.“学高为师,身正为范。”作为教师,必须加强道德修养,提高职业素质,全面关心和爱护每一位学生的身心健康发展。\n3.以激励为心育的主要手段。我们要将思想教育和学生喜闻乐见的实践活动结合起来,不断提高学生对美的感受和鉴赏力,使其求真向善,茁壮成长。\n4.形成教育合力。在抓好班集体建设的同时,我们必须密切联系家长,与家长一起研究分析学生,共同教育学生。\n5.帮助学生正确认识、分析、评价自己的心理过程。让他们将社会化标准--《中学生日常行为规范》逐渐内化,用以规范自己的言行,自觉抵制不良诱惑,不断提高自我意识水平和自我教育能力。\n6.对各类心理偏差学生施以不同的教育。对有骄傲自负心理的学生施以“挫折教育”;对有自卑、胆怯畏惧心理的学生施以“磨难教育”;对有虚荣忌妒、趋同报复、庸俗心理的学生施以分辨真美善、假丑恶的“是非教育”等。\n与此同时,还应努力提高、优化当代中学生的心理特点。\n首先,作为家长必须转变观念。对自己的孩子,在作业和职业方面的“期望值”不能脱离子女的实际而好高骛远,每个孩子因智力因素、情趣爱好,性格意志和心理承力各不相同,如果孩子确实尽了自己的努力,而未达到你所期望的目标,不应过多责怪,更不能冷嘲热讽,惩罚打骂。诚然,家长望子成龙“天经地义”,无可厚非。但“龙”的内涵并不专指读大学、考研究生。“三百六十行,行行出状元”,如果每一位家长都能建立这样的“职业观”,让孩子在宽松的环境里读书,\n其次,作为教育者----教师来说,则更要不断学习,及时吸收新鲜气息,不断提高自己的思想、政治教育水平,提高自己的专业知识和业务水平,做到不仅能教书育人,更能进行教育评价,尊重学生人格,依法执教,用先进的具有创造性的教育思想、理论、方法促进教育水平的提高,注重培养学生的全面发展,加强能力培养和思维训练,提高学生的综合素质。具体方法如下:\n第一,让学生充分了解自己的心理特点,通过与周围的同学以及其他同龄人相比,通过同电影、小说电视里特定情景中的人物相比,如宣传奥斯特洛夫斯基、托尔斯泰、张海迪、贝多芬等等,通过对比,找出自己在哪些方面存在弱点,或者也可以通过父母、老师、同学对自己经常的评价了解自己在哪些方面存在不良心理特点,从而扬长避短。\n第二,选择恰当的方法进行锻炼。例如:\n1、教他们多读好书,如《周恩来》、《钢铁是怎样炼成的》等优化心理品质。人类的几千年文明,其智慧、经验、真知灼见,都浓缩于书中,如果多读好书,能经常与这样一些“高尚朋友”对话,听听他们的“指点”以此开阔视野,启迪智慧,这对优化学生的心理品质是大有裨益的,作为中学生,不仅要读好的故事书,还应该读一些伟人的传记,读一些思想、修养方面的书籍,并且养成做读书笔记的习惯。\n2、鼓励学生参加社会活动,锻炼心理品质,如送“温暖小组”、“助残小分队”等活动的开展,都是锻炼心理品质行之有效的方法。\n3、也要注重培养学生琴、棋、书、画、音、体、美等美育活动,有助于疏导、排解不良情绪,给人以美的熏陶和享受,从而对心理产生良性刺激。让美来充实孩子的精神生活,让美来帮助塑造孩子健康的心理。\n4、在条件可能的情况下,可组织学生春游、郊游、野炊等活动,学生也可以利用寒、曙假、节假日到一些名胜古迹去游览、旅游、参观、陶冶自己的情操,走进大自然,亲近大自然,细心体会大自然,不仅能使人心胸开阔、情绪放松,精神振奋,还常能使人领悟到人生的真谛。\n只有这样,优化了学生的心理特点,才能促使学生健康成长,从而成为新世纪的合格人才。'}
{'text': '我们生产的食品消泡剂,具有可以快速消除泡沫的特点。\n丹东食品消泡剂相关内容:一般而言,纯水和纯表面活性剂不起泡,这是因为它们的表面和内部是均匀的,很难形成弹性薄膜,即使形成亦不稳定,会瞬间消失。\n丹东食品消泡剂选择:\n1. 相容性:相容性是指两种或者两种以上物质混合时,不产生相斥分离现象的能力,相容性好,消泡剂就能够长期、稳定、均匀地存在于体系中,进而发挥消抑泡的作用;反之,就会出现分层等现象,使消泡剂的消泡工作无法正常进行。\n2. 消泡能力:消泡能力是消泡剂的最主要性能,鉴别此项性能的标准是在同等条件下,分别加入等量不同的消泡剂,观察消泡剂的消泡速度。'}
{'text': '程总在座谈中首先向学校的客人介绍了三一集团和北京三一重机的情况,以及在公司快速发展过程中对人才的渴求,指出通过校企联合,学校可以依靠企业的参与制定人才培养方案,使培养的人才更贴近市场,贴近企业,又可以借助企业的资源充实学校的办学实力。同时校企联合有利于企业的可持续发展。校企联合是企业实现人才战略的途径。企业在与高等职业教育合作过程中可以贯彻自己的培养意向,满足对生产第一线实用型人才的需求。\n武汉交通职业学院盛建龙院长和河北工业职业技术学院李军锁副院长分别介绍了各自学校人才培养情况,并对三一集团的高速发展表示钦佩和赞赏,表示将和公司开展深入、全面的合作,优势互补,使学校和企业实现充分的资源共享,建立全方位长效合作机制。\n本次联合办学签约仪式,是北京桩机高起点校企合作的开始。按照北京桩机人力资源提升计划,明年北京桩机将和所高职高专院校进行联合办学成立“三一班”,均为统招大专高技学历层次,涉及焊接、装配、机加工、售后服务等紧缺工种,“三一班”学员将达到近300人,为北京桩机的下一个五年跨越式发展打下良好的人才基础。'}
{'text': '此类溶剂很可能会随着生产过程挥发出来而导致污染,其排放主要发生在投料、反应、溶剂回收、过滤、离心、烘干、出料等操作单元。\n制药废气危害\n在医药化行业中大量使用有机溶剂(如DMF、苯系物、有机胺、乙酸乙酯、二氯甲烷、丙酮、甲醇、乙醇、丁酮、乙醚、二氯乙烷、醋酸、氯仿等),挥发形成了具有刺激性气味和恶臭的气体,并具有一定毒害性,长期排放必然恶化区域大气环境质量,并对附近居民的身体产生危害。因此,有效治理制药行业VOCs污染已经成为亟待解决的重要问题。\n制药废气成分\nDMF、苯系物、有机胺、乙酸乙酯、二氯甲烷、丙酮、甲醇、乙醇、丁酮、乙醚、二氯乙烷、醋酸、氯仿等。\n制药废气特点\n(1)排放点多, 排放量大, 无组织排放严重。医药化工产品得率低, 溶剂消耗大, 溶剂废气排放点多, 且溶剂废气大多低空无组织排放, 溶剂废气浓度较高。\n(2)间歇性排放多。反应过程基本上为间歇反应, 溶剂废气也呈间歇性排放。\n(3)排放不稳定。溶剂废气成分复杂, 污染物种类和浓度变化大, 同一套装置在不同时期可能排放不同性质的污染物。\n(4)溶剂废气影响范围广。溶剂废气中的VOCs大多具有恶臭性质, 嗅域值低, 易扩散, 影响范围广。\n(5)在生产过程中易燃、易爆物质多, 反应过程激烈, 生产事故风险大。\n制药废气处理方案/ Treatment plan\n活性炭吸附方案\n当制药废气进入吸附箱后进入活性炭吸附层,由于活性炭吸附表面上存在着未平衡和未饱和的分子引力或化学键力,因此当活性炭吸附剂的表面与气体接触时,就能吸引气体分子,使其浓聚并保持在固体表面,此现象称为吸附。利用活性炭吸附剂表面的吸附能力,使废气与大表面的多孔活性炭吸附剂相接触,废气中的污染物被吸附在活性炭表面上,使其与气体混合物分离,净化后的气体高空排放。\nUV光解净化方案\nUV光解废气处理技术是指利用高能UV紫外线光束分解空气中的氧分子产生游离氧(即活性氧),因游离氧所携带正负电子不平衡所以需与氧分子结合,进而产生臭氧,臭氧具有很强的氧化性,通过臭氧对有机废气、恶臭气体进行协同光解氧化作用,使有机废气、恶臭气体物质降解转化成低分子化合物、水和二氧化碳。\n技术特点\n(1)高效除恶臭:能高效去除挥发性有机物(VOC)、无机物、硫化氢、氨气、硫醇类等主要污染物,以及各种恶臭味气体,脱臭效率可达到95%以上,脱臭效果超过国家1993年颁布的恶臭污染物排放标准(GB14554-93)和1996年颁布的《大气污染物综合排放标准》(GB16297-1996)。\n无需预处理:有机气体无需进行特殊的预处理,如加温、加湿等,设备工作环境温度在-30℃-95℃之间,湿度在30%-98%、PH值在2-11范围均可正常工作。\n(2)无需添加任何物质:只需要设置相应的排风管道和排风动力,使恶臭气体以及工业废气通过UV光解废气净化设备进行脱臭分解净化,无需添加任何物质参与化学反应。\n(3)适应性强:可适应中低浓度,不同工业废气物质的脱臭、净化处理,可每天24小时连续工作,运行稳定可靠。\n(4)运行成本低:无任何机械动作,无噪音,无需专人管理和日常维护,只需作定期检查,设备能耗低,设备风阻低<50pa,可节约大量排风动力能耗。\n(5) 安全可靠:因采用光解原理,模块采取隔爆处理,消除了安全隐患,防火、防爆、防腐蚀性能高,设备性能安全稳定,特别适用于采油(气)田、石油化工、制药等防爆要求高的行业。\n应用范围\n印刷厂、印染厂、电子厂、塑料厂、涂料厂、家具厂、炼油厂、橡胶厂、化工厂、造纸厂、皮革厂、农药厂、制药厂、油漆厂、化肥厂、食品加工厂、饲料厂、香精香料厂、屠宰厂、污水处理厂、垃圾中转站、喷涂喷漆等恶臭气体、工业废气的净化处理。\n催化燃烧方案\n蓄热式热力氧化技术是把有机废气加热到760℃以上,使废气中的VOC在氧化分解成二氧化碳和水。氧化产生的高温气体流经特制的陶瓷蓄热体,使陶瓷体升温而“蓄热”,此“蓄热”用于预热后续进入的有机废气。从而节省废气升温的燃料消耗。\n技术特点\n(1)高浓度废气处理实现自供热燃烧,运行费用低,性价比合理。\n(2)净化效率高,三室型RTO可达99.5%。\n(3)采用陶瓷蓄热体作为热能回收,预热、蓄热交替运行,热效率≥95%。\n(4)炉体钢结构牢靠,保温层厚实,运行安全可靠,稳定性高。\n(5)PLC可编程自动化控制,自动化程度高。\n(6)适用性广,可净化任何有机废气\n(7)余热利用,经济效益高;多余的热能回用至烘房、烤箱等,烘房的加热不用额外消耗燃料或电能。\n适用范围\n石油、化工、塑料、橡胶、制药、印刷、家具、纺织印染、涂布、涂料、半导体制造、合成材料等行业产生中、高浓度大风量有机废气处理,可处理有机物质种 类包括苯类、酚类、醛类、酮类、醒类、酯 类、醇类、炷类等。'}
{'text': '白癜风病人调节心理要偶尔也要屈服。能屈能伸,能进能退,轻松自如;凡事认真,一味固执,肯定烦恼重重。其实,只要大前提不受影响,一些细枝末节上的让步、妥协,是明智的,也是一种大智若愚的姿态。\n转移治愈力:很多人的烦恼是由于对于生活的期望值过高而造成的,一旦这些期望没能实现,烦恼也就随之而来。因此,正确的面对现实,踏实做自己。尽量克制自己的情绪,并将注意力转移到学习、工作、娱乐或者其他感兴趣的方面,可以通过听歌、跳舞等娱乐活动来忘记心中的苦闷,还可以通过体育运动来消除不良的情绪,这样就不至于越想越难过了。贵州白癜风\n白癜风患者怎样更好的减轻心理压力生活中要消除各种精神刺激,白癜风患者心理努力改善精神状态和不良的生活、工作环境,保持良好的心理,增强自身免疫功能,及早发现,及时治疗,并持之以恒。而且,在治疗的过程中,还要根据不同的病因,采用不同的治疗方法,同时要保持精神乐观,心情舒畅,切勿悲观急躁,尽力解除一切不必要的思想顾虑。\n专家建议一旦发现在即皮肤出现白斑最好还是到正规的医院去进行确诊,然后在医生的指导下进行治疗。不要自己判断完了就自行用药,以免给自己带来不必要的伤害。'}
{'text': '对全校教学保障、教学建设、教学管理、教学运行和教学改革等进行质量监控和评价。\n贯彻执行党的教育方针和上级教育部门文件精神,制定并组织实施学校教学质量监控与评估的相关文件及规章制度。\n依据《博鱼平台入口(中国)有限公司各主要教学环节质量标准和评价方案》等规章制度,定期开展课堂教学、课程考核、毕业设计(论文、创作)、实验(实践)教学等主要教学环节的检查与评价工作,规范教学行为。\n组织学校本科教学基本状态数据库的采集工作,进行数据整理、分析。充分利用信息技术,全面如实反映学校的办学状况以及在教学质量提升方面采取的措施和存在的问题,建立本科教学工作及其质量常态监控机制。\n积极开展各类教学状况信息的收集和整理,并做好分析、反馈和整改工作。组织学校年度本科教学质量报告的撰写和发布工作,在客观反映学校教学质量的同时,分析学校教学质量发展的动态趋势,并揭示学校在人才培养和教学质量中存在的问题,提出具体的改进措施。'}
{'text': '有趣的是,库里在第三节上篮时被防守球员犯规,但裁判并未理会,怒不可遏的库里对着裁判一顿输出,随后怒吃一T。这彻底激怒了他,在随后的75秒内,库里连中3记三分带走比赛,而在命中本场比赛第7记三分后,库里还学裁判比出给T的手势热烈庆祝!在赛后采访时,库里谈到比T的庆祝手势:“很明显,我认为我被犯规了,所以我想发泄情绪,然后你就会放开了,并且专心的打篮球。'}
{'text': '担任地点省市的区域运营中心的办理作业。承受总部相关KPI查核。\n1、了解新闻职业或媒体相关运营运营岗位,其间,应聘区域运营中心主任有3年以上当地干流媒体作业经验者优先,应聘事务主管有2年以上当地干流媒体作业经验者优先。\n2、交流才能强,抗压才能强,长于处理复杂情况,了解GR作业优先,能独立完结策划计划优先。具有独立开发客户才能。\n北京、天津、河北、山西、黑龙江、吉林、辽宁、上海、江苏、浙江、安徽、江西、福建、山东、河南、湖北、湖南、广东、海南、重庆、四川、贵州、云南、陕西等。'}
{'text': '在党建展览馆,全体党员跟随解说员通过开天辟地、改天换地、翻天覆地、惊天动地、红心向党五部分的学习,重温了中国共产党团结带领中国人民铸就百年辉煌的不懈奋斗、不怕牺牲、理论探索为民造福、自身建设的壮阔历程,深刻认识到红色政权来之不易、新中国来之不易、中国特色社会主义来之不易。随后,新发展的学生党员在庄严的国旗和党旗下郑重宣誓,接受了心灵的洗礼。\n参观结束后全体党员进行了党性体检,通过查找不足,大家进一步加强了党性锻炼,筑牢了党员初心。此次活动,外语系党总支利用校内红色资源,使全体党员进一步深刻认识到了中国共产党为什么能、马克思主义为什么行、中国特色社会主义为什么好。在以后的工作生活中,大家将进一步继承我党光荣传统和优良作风,为祖国的教育事业贡献自己的一份力量。'}
{'text': '1962年始,他開始了新的寫作嘗試,即不用標點符號。這一年他的小說《天堂之門》問世,16年後該小說被譯成英語,在倫敦出版。小說描寫十字軍東征背後的種種動機,整部小說為一個句子,前40000個詞沒有用一個標點符號。\n2.紀洛姆·阿波裡耐(1880-1918)\n他是一位意大利軍官的非婚生子,出生後被父母遺棄,他是在法國利維拉省的教會學校中長大的。後來他到了巴黎,當過教師、教士、捉刀代筆人,還寫過色情小說。最後他成了文學批評家、小說家和詩人。他是未來派和立體派的創始人,並創造了"超現實主義"這個術語。他發現了原始派畫家亨利·盧索,畢加肅、布拉克、馬提西、弗拉明克和杜菲等畫家也都是他的好友。1913年他的詩集《醇酒集》使他一舉成名,這部作品激怒了許多批評家,因為裡面沒有標點符號。對此,阿波裡耐自己是這樣解釋的:「我擯棄標點符號,因為我覺得它們毫無用處,事實也確實如此,詩的節奏和詩行就是真正的標點,除此之外,什麼也不需要了。」\n3.提摩西·德克斯特(1747-1806)\n德克斯特在從事寫作之前做過商人,並因此發了大財。他在麻薩諸塞州的紐卡斯爾買了一幢很大的邸宅,門前放著40個真人大小的木雕塑像,其中有尼爾遜、亞當、夏娃、華盛頓、路易十四,還有他自己。在邸宅中他供養了一位妻子,一個星相學家,一個大塊頭宮廷小丑,一位非洲公主的女管家,還有一位充當桂冠詩人的魚販子。1802年德克斯特開始寫作,出版了一本24頁的自傳體哲學小冊子,題為《智者的困惑》。在這本書中,德克斯特竭力為自己的生活方式辯護,他描寫了自己發財的過程,並暗示他可以成為美國的好皇帝。這本書的一個顯著特點是它只包含一個句子,或者說不包含任何句子。書中沒有任何標點符號,從語法上說,這個句子沒有開頭也沒有結束。它就像時間和空間一樣,來自無限,並歸於無限。人們對這一"文學瑰寶"的反應使德克斯特意識到一部沒有標點符號的書實在算不上一本書。在這本書的第二版中,他增加了一頁,這一頁中有整整13行全是標點符號—-一行又一行的分號、逗號、問號、句號。德克斯特說:讀者們可以"隨心所欲"地自行在他書中的字裡行間"撒點胡椒和鹽"。\n4.約翰·多布遜牧師(1794-1847)\n多布遜牧師以數學見長,也從事文學寫作,但他是以憎恨標點符號而出名的。1815年,他的兩卷本《幾何原理》出版了,全書除了每段末尾用了一個句號外,沒有任何標點符號。不過這本書的完整性被出版商偷偷地破壞了,他在第一頁中加上了一個分號、一個冒號、一個逗號和一個句號。\n5.哥楚特·斯泰因(1874-1946)\n這位賓夕法尼亞出生的先鋒派女作家後來成了巴黎最著名的美籍作家之一。畢加索和海明威都是她的好友,她的沙龍和藝術收藏品是舉世聞名的。她的地位是由《三個女人的一生》和《艾麗斯·B·托克拉斯的自傳》奠定的。她的風格——簡單化與重複——獨具匠心,她自認這種風格在詹姆士·喬伊斯之上。但最突出的是她藐視標點符號。斯泰因認為「句號有著自己的生命",所以她只用句號。有時她會不留神寫下一個逗號,不過她十分厭惡逗號,認為它"具有奴性",使得作品讀起來過份容易。她認為問號和感歎號"具有鮮明的背叛性"。斯泰因最出名的字行是:「玫瑰就是玫瑰就是玫瑰就是玫瑰。"她終生未婚,與一位叫艾麗斯·B·托克拉斯的女子過著同性戀生活。\n6.E·卡明斯\n卡明斯是波士頓人,他既是詩人,又是畫家,他在文壇贏得一個令人不安的外號:「可怕的孩童"。他隨心所欲地使用標點符號、語法和排版,最初令評論家"大為迷惑",他們指責他"想入非非",不過,後來他成了一個出名的詩人。卡明斯憎惡句號和逗號,但他喜歡括弧和連接號。他有些詩中沒有一個標點,這種奇特的風格吸引了一些模仿者,不過他們缺乏卡明斯詩中獨特的韻味。\n後一頁\n前一頁\n回目錄\n'}
{'text': '妈妈感慨“美好的一天”,也能作息规律,专注工作。\n愿所有的美好如约而至,世界上没有父母不爱自己的孩子,世界上也没有不想学好的孩子,亲子之间,从相对无言到知无不言,从横眉冷对到设身处地,从针锋相对到理解包容,可能只需要一个转变的契机。\n如果你日日夜夜的坐在电脑前,沉浸在一个干扰你的习惯、新陈代谢和内部时钟的虚拟世界中,那它怎么可能对你的智力发展有好处呢?对网络的迷恋和对学习没有兴趣,学业成绩形成了恶性循环。国内外研究还表明,青少年长期沉迷于网络中,除了影响头部发育外,还会导致植物神经紊乱、激素水平失衡,使免疫功能降低,引起紧张性头痛、焦虑,甚至导致死亡。\n网上的一些内容很能抓住青少年的心理,网上游戏就是其中的一种,有些网络游戏与赌博的性质差不多,这种游戏采取积分上层次的方式。玩者第一次在网上玩,打到一定的分数,如果再打一定的分数就可上一个层次,上了层次其功能和能力就会更高更强。有了这种不断上层次、能力不断增强的吸引,孩子很容易玩上瘾。 网吧的宽容使得家长和学校管理孩子的难度越来越大。因为人们可以从网络上学习到各种信息,所以网上的很多孩子都很自豪,在网上看到新奇的内容,他们可以在同学、同伴之间互相炫耀,这种虚荣心使没有进入网吧的孩子大量走进网吧的大门。在孩子们的眼中,网吧是非常时尚的,而目前的舆论对于“网虫”还是开放的一面,并没有批评的成分。所以,不少青少年虽然把钱“送”给了网吧,他们却认为自己的做法是前卫的表现。\n开展心理健康教育的必要性是不言而喻的,同时,这也是一项很讲究科学与方法的工作。我们要坚持科学的态度和遵循心理发展规律以及在正确的教育思想指导下,结合心理学基本原理和运用科学的方法,深入全面地了解学生的实际问题,这样才能为较好地实现心理健康教育目标提供现实的可能性。\n网络成瘾的治疗已成为时代的紧迫任务。这意味着,如果任何人能够真正拥有一套有效的医学互联网成瘾技术,他们将拥有巨大的市场,甚至带来巨大的利润。对休克疗法本身的重视证明了治疗网络成瘾的迫切需要。\n在我看来,网络成瘾的治疗,是不能放弃的。一些网络游戏成为魔鬼吞噬灵魂的最重要原因是网络游戏行业严重缺乏规范管理。近年来,网络游戏产业取得了长足的发展,网络游戏越来越让人上瘾。但是,网络游戏缺乏有效的法治管理。在这种形势下,网络游戏产业日益背离公共责任,产生了一些“电子海洛酮”,使得缺乏自我保护意识的未成年人成瘾成为一场灾难。'}
{'text': '二、企业信息化的内涵\n1、目标:企业进行信息化建设的目的是“增强企业的核心竞争力”。\n2、手段:计算机网络技术。\n3、涉及的部门:企业的各个部门,包括:企业的生产、经营、设计、

我们可以看见,本质上来说,就是·从各个地方收集整理的数据,每一行之间也没有前后相互的关联。但是同一行的,是完整的一段话,但是这个也不要紧,后面我们还会对这个数据进行进一步的切分。

下面我们来看看如何抽取小的一个数据集。

很显然,我们可以获取这个数据集的总行数,然后截取1/10即可。

代码地址:Code/split_small_dataset.py

from tqdm import tqdm


file_path = "/root/autodl-tmp/Dataset/mobvoi_seq_monkey_general_open_corpus.jsonl"

count = 0
with open(file_path, "r", encoding="utf-8", errors='ignore') as f:
    for _ in f:
        count += 1

subset_lines = count // 10

output_file = "/root/StudyLLM/NX_LLM/第二章 动手实现/Dataset/mobvoi_seq_monkey_general_open_corpus_min.jsonl"

with open(file_path, "r", encoding="utf-8", errors="ignore") as fin, \
        open(output_file, "w", encoding="utf-8") as fout, \
        tqdm(total=subset_lines, desc="抽取进度", unit="行") as pbar:
    for i, line in enumerate(fin):
        if i >= subset_lines:
            break
        fout.write(line)
        pbar.update(1)

print(f"已完成抽取,结果保存到:{output_file}")

数据处理(文本切块)
#

文本有了,那么我们为什么要切块呢?

大家肯定经常听说,大模型上下文窗口,是多大,多大。这个就是每个模型输入的最大限制,也叫做序列长度,每个语言模型都有一个固定的最大上下文长度(context length)。

模型最大长度(token数)
GPT-2 / BERT512
LLaMA 24096
LLaMA 38192
GPT-4-turbo可达 128k

这里我们为了节约算力,我们的窗口上下文就设置为256吧。

这里为了保证切块的时候,具有一定的上下文,我们采用滑动窗口的形式,并且并且保证有32个字是重复的,这个我们应该怎么做呢?

text = (
    "在自然语言处理中,语言模型是一种用于计算句子概率的模型。"
    "它可以被用来生成文本、翻译语言、回答问题。"
    "为了让模型能够理解更复杂的上下文,我们通常需要将长文本切分为较短的片段。"
)


chunk_len = 50
overlap = 10

chunks = []
strat = 0
step = chunk_len - overlap

while strat < len(text):
    end = min(strat + chunk_len, len(text))
    chunk = text[strat: end]
    chunks.append(chunk)
    if end == len(text):
        break
    strat += step

for i, c in enumerate(chunks):
    print(f"--- chunk {i} ({len(c)}字) ---")
    print(c)
    print()
/root/miniconda3/bin/python3 /root/StudyLLM/NX_LLM/第二章 动手实现/Code/deal_dataset_pre.py 
--- chunk 0 (50) ---
在自然语言处理中语言模型是一种用于计算句子概率的模型它可以被用来生成文本翻译语言回答问题

--- chunk 1 (45) ---
译语言回答问题为了让模型能够理解更复杂的上下文我们通常需要将长文本切分为较短的片段

下面是将我们完整的小数据集全部进行切分为256长度的块。

def split_text(text, chunk_size=256, overlap=32):
    *"""*
*    将文本按指定长度切分成块,支持滑动窗口,保证相邻块有 overlap 个字符重叠。*

*    参数:*
*        text (str): 待切分文本*
*        chunk_size (int): 每块最大字符数*
*        overlap (int): 相邻块重叠字符数*

*    返回:*
*        List[str]: 切分后的文本块列表*
*    """*
*    *if overlap >= chunk_size:
        raise ValueError("overlap 必须小于 chunk_size")
    chunks = []
    step = chunk_size - overlap
    start = 0
    n = len(text)

    while start < n:
        end = min(start + chunk_size, n)
        chunks.append(text[start:end])
        if end == n:
            break
        start += step  # 滑动窗口
    return chunks


pretrain_data = "/root/StudyLLM/NX_LLM/第二章 动手实现/Dataset/mobvoi_seq_monkey_general_open_corpus_min.jsonl"
output_pretrain_data = "/root/StudyLLM/NX_LLM/第二章 动手实现/Dataset/pretrain_data.jsonl"

with open(output_pretrain_data, 'a', encoding='utf-8', errors='ignore') as pretrain:
    # 注意这里加了 errors='ignore',避免非 utf-8 字节报错
    with open(pretrain_data, 'r', encoding='utf-8', errors='ignore') as f:
        for line in tqdm(f, desc=f"Processing lines in {pretrain_data}", leave=False):
            try:
                line = json.loads(line)   # 尝试解析 JSON
                text = line.get('text', '')  # 保险:如果没有 text 字段返回空字符串
                chunks = split_text(text)
                for chunk in chunks:
                    pretrain.write(json.dumps({'text': chunk}, ensure_ascii=False) + '\n')
            except Exception as e:
                # 如果某行解析失败(坏行 / 编码问题),跳过
                print(f"跳过坏行: {e}")

我们用之前的预览代码查看一下:

Image
Image

可以看见,我们是成功处理了。下面我们就要把数据先放一边了,我们开始将模块一个一个编写完成。下面要讲的是

Tokenizers 组件
#

将输入的 token(数字化)映射为向量表示。我们一般都是使用Tokenizer这个模型。我们用句子 “我喜欢南巷的花猫” 来具体说明 Tokenizer 的工作原理。这里我举两个常见示例:字符级子词(BPE / SentencePiece)级,并展示 Token ID 转换过程。

好的,我们用句子 “我喜欢南巷的花猫” 来具体说明 Tokenizer 的工作原理。这里我举两个常见示例:字符级子词(BPE / SentencePiece)级,并展示 Token ID 转换过程。

原理
#


1、原始文本

我喜欢南巷的花猫

长度:7 个中文字符。


2、字符级 Tokenizer

  • 每个字符就是一个 token

  • 适合中文简单示例

text = "我喜欢南巷的花猫"
tokens = list(text)
print(tokens)

输出:

['我', '喜', '欢', '南', '巷', '的', '花', '猫']

如果我们给每个字符分配整数 ID(假设简单映射):

我 -> 101
喜 -> 102
欢 -> 103
南 -> 104
巷 -> 105
的 -> 106
花 -> 107
猫 -> 108

那么token IDs就是:

[101, 102, 103, 104, 105, 106, 107, 108]

3、 BPE / SentencePiece 子词 Tokenizer

  • 模型训练时常用

  • 对中文长句通常按子词拆分(不是单字,也不是整词)

  • 假设 LLaMA2 的 tokenizer 处理这个句子可能结果如下(示例):

from transformers import LlamaTokenizer

tokenizer = LlamaTokenizer.from_pretrained("meta-llama/Llama-2-7b-hf")
text = "我喜欢南巷的花猫"

encoding = tokenizer(text)
print("token IDs:", encoding["input_ids"])
print("tokens:", tokenizer.convert_ids_to_tokens(encoding["input_ids"]))

可能输出(示意)

token IDs: [1, 230, 543, 876, 456, 13, 2]
tokens: ['<s>', '我', '喜欢', '南巷', '的', '花猫', '</s>']
  • <s></s> 是起始/结束 token

  • 中文被拆成若干子词:喜欢南巷花猫

  • 这些 token ID 是模型 embedding 表中对应的索引,模型只理解这些数字。


4、逆过程:Token ID → 文本

decoded_text = tokenizer.decode(encoding["input_ids"])
print(decoded_text)

输出:

我喜欢南巷的花猫

Tokenizer 保证文本和 token ID 之间可以一一映射,方便模型训练与生成。


5、总结

层次Token 示例Token ID 示例
字符级[‘我’,‘喜’,‘欢’,‘南’,‘巷’,‘的’,‘花’,‘猫’][101,102,103,104,105,106,107,108]
子词级 (BPE)[’’,‘我’,‘喜欢’,‘南巷’,‘的’,‘花猫’,’’][1,230,543,876,456,13,2]
  • 字符级:粒度小,序列长

  • 子词级:粒度适中,更节省 token 数,适合 LLaMA、GPT 预训练

  • Token ID 是模型真正能理解的数字,所有计算都在 token ID 上进行

好了,那让我们看看怎么做吧,看了这么多原理,上代码前先把这些都安装上:

pip install tokenizers datasets transformers

我们的目标是:目标是 从零训练一个自定义的中文 BPE Tokenizer,并配置特殊 token 和聊天模板,使它可以用于中文对话模型的预训练或微调。

主要流程包括:

  • 读取 JSONL 格式的中文文本语料

  • 用 BPE 模型训练 tokenizer

  • 保存 tokenizer 配置和文件

  • 测试 tokenizer 是否正常工作(编码、解码、特殊 token、聊天模板)

关于BPE Tokenizer的原理和介绍看第一章的LLama2解析。

这里也再次回顾一下,因为这个确实会比较陌生一点。

假设我们有一个非常简单的文本:我喜欢南巷的花猫

Step 1:初始化

把文本拆成最小单位(通常是字符或字节):

["我", "喜", "欢", "南", "巷", "的", "花", "猫"]

每个字符初始都是一个 token

Step 2:统计频率

统计 连续 token 对(pair) 出现的频率

例如如果文本中出现了 "喜欢 南" 很多次,就记录 pair ("喜欢","南") 的频率

Step 3:合并最高频 pair

找到频率最高的 pair,合并成一个新的 token

例如:("南","巷") → "南巷"

文本变为:

["我", "喜", "欢", "南巷", "的", "花", "猫"]

Step 4:重复迭代

再次统计连续 token 对频率,合并最高频的

("喜欢","南巷") → "喜欢南巷"

文本变为:

["我", "喜欢南巷", "的", "花", "猫"]

迭代直到达到 vocab_size 或没有可合并的 pair

训练词表
#

这里直接上代码:

import random
import json
import os
from transformers import AutoTokenizer
from tokenizers import (
    decoders,
    models,
    pre_tokenizers,
    trainers,
    Tokenizer,
)
from tokenizers.normalizers import NFKC
from typing import Generator

random.seed(42)


def read_texts_from_jsonl(file_path: str) -> Generator[str, None, None]:
    *"""读取JSONL文件并安全提取文本数据"""*
*    *with open(file_path, 'r', encoding='utf-8') as f:
        for line_num, line in enumerate(f, 1):
            try:
                data = json.loads(line)
                if 'text' not in data:
                    raise KeyError(f"Missing 'text' field in line {line_num}")
                yield data['text']
            except json.JSONDecodeError:
                print(f"Error decoding JSON in line {line_num}")
                continue
            except KeyError as e:
                print(e)
                continue


def create_tokenizer_config(save_dir: str) -> None:
    *"""创建完整的tokenizer配置文件"""*
*    *config = {
        "add_bos_token": False,
        "add_eos_token": False,
        "add_prefix_space": False,
        "bos_token": "<|im_start|>",
        "eos_token": "<|im_end|>",
        "pad_token": "<|im_end|>",
        "unk_token": "<unk>",
        "model_max_length": 256,
        "clean_up_tokenization_spaces": False,
        "tokenizer_class": "PreTrainedTokenizerFast",
        "chat_template": (
            "{% for message in messages %}"
            "{% if message['role'] == 'system' %}"
            "<|im_start|>system\n{{ message['content'] }}<|im_end|>\n"
            "{% elif message['role'] == 'user' %}"
            "<|im_start|>user\n{{ message['content'] }}<|im_end|>\n"
            "{% elif message['role'] == 'assistant' %}"
            "<|im_start|>assistant\n{{ message['content'] }}<|im_end|>\n"
            "{% endif %}"
            "{% endfor %}"
            "{% if add_generation_prompt %}"
            "{{ '<|im_start|>assistant\n' }}"
            "{% endif %}"
        )
    }

    # 保存主配置文件
    with open(os.path.join(save_dir, "tokenizer_config.json"), "w", encoding="utf-8") as f:
        json.dump(config, f, ensure_ascii=False, indent=4)

    # 创建special_tokens_map.json
    special_tokens_map = {
        "bos_token": "<|im_start|>",
        "eos_token": "<|im_end|>",
        "unk_token": "<unk>",
        "pad_token": "<|im_end|>",
        "additional_special_tokens": ["<s>", "</s>"]
    }
    with open(os.path.join(save_dir, "special_tokens_map.json"), "w", encoding="utf-8") as f:
        json.dump(special_tokens_map, f, ensure_ascii=False, indent=4)


def train_tokenizer(data_path: str, save_dir: str, vocab_size: int = 8192) -> None:
    *"""训练并保存自定义tokenizer"""*
*    *os.makedirs(save_dir, exist_ok=True)

    # 初始化tokenizer
    tokenizer = Tokenizer(models.BPE(unk_token="<unk>"))
    tokenizer.normalizer = NFKC()  # 添加文本规范化
    tokenizer.pre_tokenizer = pre_tokenizers.ByteLevel(add_prefix_space=False)
    tokenizer.decoder = decoders.ByteLevel()

    # 配置特殊token
    special_tokens = [
        "<unk>",
        "<s>",
        "</s>",
        "<|im_start|>",
        "<|im_end|>"
    ]

    # 配置训练器
    trainer = trainers.BpeTrainer(
        vocab_size=vocab_size,
        special_tokens=special_tokens,
        min_frequency=2,  # 提高低频词过滤
        show_progress=True,
        initial_alphabet=pre_tokenizers.ByteLevel.alphabet()
    )

    # 训练tokenizer
    print(f"Training tokenizer with data from {data_path}")
    texts = read_texts_from_jsonl(data_path)
    tokenizer.train_from_iterator(texts, trainer=trainer, length=os.path.getsize(data_path))

    # 验证特殊token映射
    try:
        assert tokenizer.token_to_id("<unk>") == 0
        assert tokenizer.token_to_id("<s>") == 1
        assert tokenizer.token_to_id("</s>") == 2
        assert tokenizer.token_to_id("<|im_start|>") == 3
        assert tokenizer.token_to_id("<|im_end|>") == 4
    except AssertionError as e:
        print("Special tokens mapping error:", e)
        raise

    # 保存tokenizer文件
    tokenizer.save(os.path.join(save_dir, "tokenizer.json"))

    # 创建配置文件
    create_tokenizer_config(save_dir)
    print(f"Tokenizer saved to {save_dir}")


def eval_tokenizer(tokenizer_path: str) -> None:
    *"""评估tokenizer功能"""*
*    *try:
        tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
    except Exception as e:
        print(f"Error loading tokenizer: {e}")
        return

    # 测试基本属性
    print("\n=== Tokenizer基本信息 ===")
    print(f"Vocab size: {len(tokenizer)}")
    print(f"Special tokens: {tokenizer.all_special_tokens}")
    print(f"Special token IDs: {tokenizer.all_special_ids}")

    # 测试聊天模板
    messages = [
        {"role": "system", "content": "你是一名住在南巷的居民,熟悉小巷生活和街坊习惯。"},
        {"role": "user", "content": "我今天在南巷遇到一只花猫,好可爱!"},
        {"role": "assistant", "content": "太棒了!南巷的花猫很友好,你给它取名字了吗?"},
        {"role": "user", "content": "还没有,你有什么建议吗?"},
        {"role": "assistant", "content": "可以叫它‘巷巷’,听起来很有南巷的感觉。"},
    ]

    print("\n=== 聊天模板测试 ===")
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        # add_generation_prompt=True
    )
    print("Generated prompt:\n", prompt, sep="")

    # 测试编码解码
    print("\n=== 编码解码测试 ===")
    encoded = tokenizer(prompt, truncation=True, max_length=256)
    decoded = tokenizer.decode(encoded["input_ids"], skip_special_tokens=False)
    print("Decoded text matches original:", decoded == prompt)

    # 测试特殊token处理
    print("\n=== 特殊token处理 ===")
    test_text = "<|im_start|>user\nHello<|im_end|>"
    encoded = tokenizer(test_text).input_ids
    decoded = tokenizer.decode(encoded)
    print(f"Original: {test_text}")
    print(f"Decoded:  {decoded}")
    print("Special tokens preserved:", decoded == test_text)


def main():
    # 配置路径
    data_path = "your data path"
    save_dir = "tokenizer_k"

    # 训练tokenizer
    train_tokenizer(
        data_path=data_path,
        save_dir=save_dir,
        vocab_size=6144
    )

    # 评估tokenizer
    eval_tokenizer(save_dir)


if __name__ == '__main__':
    main()

正在拼命的统计分词

Image

在训练的过程中,很可能会有较大的内存消耗,我租用的3090服务器90G内存都爆了,好几次,没办法后面换4090的机子,有120G内存,这里就先给大家训练好了词表。也同时放到项目中了

地址:第二章 动手实现/tokenizer_k

下面单独给出一段测试代码:

from transformers import AutoTokenizer




def eval_tokenizer(tokenizer_path: str) -> None:
    *"""评估tokenizer功能"""*
*    *try:
        tokenizer = AutoTokenizer.from_pretrained(tokenizer_path)
    except Exception as e:
        print(f"Error loading tokenizer: {e}")
        return

    # 测试基本属性
    print("\n=== Tokenizer基本信息 ===")
    print(f"Vocab size: {len(tokenizer)}")
    print(f"Special tokens: {tokenizer.all_special_tokens}")
    print(f"Special token IDs: {tokenizer.all_special_ids}")

    # 测试聊天模板
    messages = [
        {"role": "system", "content": "你是一名住在南巷的居民,熟悉小巷生活和街坊习惯。"},
        {"role": "user", "content": "我今天在南巷遇到一只花猫,好可爱!"},
        {"role": "assistant", "content": "太棒了!南巷的花猫很友好,你给它取名字了吗?"},
        {"role": "user", "content": "还没有,你有什么建议吗?"},
        {"role": "assistant", "content": "可以叫它‘巷巷’,听起来很有南巷的感觉。"},
    ]

    print("\n=== 聊天模板测试 ===")
    prompt = tokenizer.apply_chat_template(
        messages,
        tokenize=False,
        # add_generation_prompt=True
    )
    print("Generated prompt:\n", prompt, sep="")

    # 测试编码解码
    print("\n=== 编码解码测试 ===")
    encoded = tokenizer(prompt, truncation=True, max_length=256)
    print("Encoded input_ids:", encoded["input_ids"])
    decoded = tokenizer.decode(encoded["input_ids"], skip_special_tokens=False)
    print("Decoded text matches original:", decoded == prompt)

    # 测试特殊token处理
    print("\n=== 特殊token处理 ===")
    test_text = "<|im_start|>user\nHello<|im_end|>"
    encoded = tokenizer(test_text).input_ids
    decoded = tokenizer.decode(encoded)
    print(f"Original: {test_text}")
    print(f"Decoded:  {decoded}")
    print("Special tokens preserved:", decoded == test_text)


eval_tokenizer('/root/StudyLLM/NX_LLM/第二章 动手实现/tokenizer_k')

结果为:

Image
Image

我们可以看见,已经成功将文字转换为id,自此,我觉得很有必要回到我绘制的那一张图了:

Image

是不是感觉突然就懂了,好了,可以回头继续再看一遍是怎么把这个词表做出来的。

OK,上面我们已经完成了数据处理,词表的训练,全部的基础工作已经做完了,下面到我们核心的各个组件是怎么编写的了。其实也非常的简单,只是将公式,有代码的方式来表达就行。

RMSNorm(Root Mean Square Normalization)
#

聚焦公式
#

$y = \frac{x }{\sqrt{mean(x^{2}) + \epsilon } } \times \gamma$

这个过于简单,就不拆解了
#

这个实现起来是最简单的,就算是没有学过深度学习的图像,也能直接秒了:

代码地址:第二章 动手实现/Code/RMSNorm.py

import torch
from torch import nn


"""
种归一化有助于通过确保权重的规模不会变得过大或过小来稳定学习过程,这在具有许多层的深度学习模型中特别有用。
"""


class RMSNorm(nn.Module):
    *"""*
*    RMSNorm (Root Mean Square Layer Normalization)*
*    归一化方法:*
*        RMSNorm(x) = x / sqrt(mean(x^2) + eps) * weight*
*    与 LayerNorm 的区别:*
*        - 不减去均值*
*        - 更轻量,计算效率高*
*        - 常用于大型 Transformer 模型,如 LLaMA*
*    """*

*    *def __init__(self, dim: int, eps: float = 1e-8):
        *"""*
*        Args:*
*            dim (int): 输入特征维度*
*            eps (float): 避免除以零的微小常数*
*        """*
*        *super().__init__()
        self.eps = eps
        # 可学习的缩放参数 γ,初始化为 1
        self.weight = nn.Parameter(torch.ones(dim))

    def _rms_norm(self, x: torch.Tensor) -> torch.Tensor:
        *"""*
*        核心归一化操作:按最后一个维度计算 RMS*
*        Args:*
*            x (torch.Tensor): 输入张量,形状 (..., dim)*
*        Returns:*
*            torch.Tensor: 归一化后的张量*
*        """*
*        *# 计算均方根
        rms = torch.sqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)
        # 将输入除以 RMS,实现归一化
        return x / rms

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        *"""*
*        前向传播*
*        Args:*
*            x (torch.Tensor): 输入张量,形状 (..., dim)*
*        Returns:*
*            torch.Tensor: 归一化并缩放后的输出*
*        """*
*        *# 转为 float 避免低精度问题
        x_norm = self._rms_norm(x.float())
        # 转回原始数据类型,并乘以可学习权重 γ
        return x_norm.type_as(x) * self.weight




if __name__ == "__main__":
    norm = RMSNorm(768, 0.001)
    x = torch.randn(1, 60, 768)
    output = norm(x)
    print(output.shape)

测试结果:

torch.Size([1, 60, 768])

Grouped Multi Query Attention
#

在这个地方,因为旋转嵌入是直接作用在QK矩阵中,所以,这里我们直接先带着将旋转嵌入的代码写完。再接着将,GQA的代码写。

下面将整体的框架图绘制好了,我们一步一步实现就行。

1、旋转嵌入
#

上公式:

预计算旋转频率

$\text{freqs}[t, i] = \frac{1}{\theta^{i / (\text{dim}/2)}} \cdot t$

$\text{cos}[t, i] = \cos(\text{freqs}[t, i]), \quad$ $\text{sin}[t, i] = \sin(\text{freqs}[t, i])$

  • $i \in [0, seqlen - 1]$

  • $i \in [0, \text{dim}/2 - 1]$

这是对每个位置和每个偶数维度计算的旋转角度 θ,然后求 cos/sin。


旋转嵌入应用

假设 query 的某个 head 的向量拆成实部 ($x_r$) 和虚部 ($x_i$)(每两个维度一组):

$ x'_r = x_r \cdot \cos\theta - x_i \cdot \sin\theta$

$x'_i = x_r \cdot \sin\theta + x_i \cdot \cos\theta$

这里的 (\theta = \text{freqs}[t, i]),对应每个位置 t 和每对维度 i。


广播对齐

为了把 cos/sin 张量广播到 [batch, seq_len, n_head, head_dim//2] 的形状:

$ \text{freqscosbroad}[b, t, h, i] = \cos(\theta_{t,i})$

  • b:batch

  • t:序列位置

  • h:head

  • i:每对维度的索引

所以广播就是在 batch 和 head 维度上复制。


最终合并

代码:

xq_out = torch.stack([xq_out_r, xq_out_i], dim=-1).flatten(3)

公式:

把每对维度的旋转结果再合并回原始向量:

$ x'_{q} = flatten([x^{'}_{r}], [x^{'}_{i}])$

  • 每两个维度一对旋转

  • flatten 后恢复原始 head 维度

代码地址:

完整的代码为:Code/ROPE.py

import torch
from typing import Tuple

# -----------------------------
# 1计算旋转频率
# -----------------------------
def precompute_rotary_freqs(dim: int, seq_len: int, theta: float = 10000.0) -> Tuple[torch.Tensor, torch.Tensor]:
    *"""*
*    dim: 每个head的维度*
*    seq_len: 序列长度*
*    theta: 基数,通常10000*
*    """*
*    *# dim维度的一半,用于正余弦计算
    half_dim = dim // 2

    # 频率:1 / theta^(i/dim)
    freqs = 1.0 / (theta ** (torch.arange(half_dim).float() / half_dim))

    # 生成 seq_len x half_dim 的矩阵
    t = torch.arange(seq_len).unsqueeze(1)
    freqs = t * freqs.unsqueeze(0)

    # 计算正余弦
    return torch.cos(freqs), torch.sin(freqs)

# -----------------------------
# 2调整频率形状以广播
# -----------------------------
def reshape_for_broadcast(freqs: torch.Tensor, x: torch.Tensor) -> torch.Tensor:
    *"""*
*    将 freqs 调整成可以广播到 x 的形状*
*    """*
*    *shape = [1] * x.ndim
    shape[1] = freqs.shape[0]  # 序列长度
    shape[-1] = freqs.shape[1]  # head维度
    return freqs.view(shape)

# -----------------------------
# 3应用旋转嵌入
# -----------------------------
def apply_rotary_emb(
    xq: torch.Tensor,
    xk: torch.Tensor,
    cos: torch.Tensor,
    sin: torch.Tensor
) -> Tuple[torch.Tensor, torch.Tensor]:
    *"""*
*    xq, xk: [batch, seq_len, n_head, head_dim]*
*    cos, sin: [seq_len, head_dim//2]*
*    """*
*    *# 先拆成实部和虚部
    xq_r, xq_i = xq.reshape(*xq.shape[:-1], -1, 2).unbind(-1)
    xk_r, xk_i = xk.reshape(*xk.shape[:-1], -1, 2).unbind(-1)

    # 调整频率形状以广播
    cos = reshape_for_broadcast(cos, xq_r)
    sin = reshape_for_broadcast(sin, xq_r)

    # 应用旋转公式
    xq_rot = torch.stack([xq_r * cos - xq_i * sin, xq_r * sin + xq_i * cos], dim=-1).flatten(-2)
    xk_rot = torch.stack([xk_r * cos - xk_i * sin, xk_r * sin + xk_i * cos], dim=-1).flatten(-2)

    return xq_rot.type_as(xq), xk_rot.type_as(xk)

# -----------------------------
# 4示例
# -----------------------------
if __name__ == "__main__":
    batch, seq_len, n_head, head_dim = 1, 50, 6, 48
    xq = torch.randn(batch, seq_len, n_head, head_dim)
    xk = torch.randn(batch, seq_len, n_head, head_dim)

    cos, sin = precompute_rotary_freqs(head_dim, seq_len)
    xq_out, xk_out = apply_rotary_emb(xq, xk, cos, sin)

    print("xq_out shape:", xq_out.shape)
    print("xk_out shape:", xk_out.shape)
Image

2、复制KV
#

代码地址:第二章 动手实现/Code/Attention.py

下面是进行KV复制的代码操作。

def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:
    *"""*
*    重复 key/value 的头,保证与 query 的头数对齐*

*    x: [batch, seq_len, n_kv_heads, head_dim]*
*    n_rep: 重复次数*
*    """*
*    *if n_rep == 1:
        return x  # 不需要重复,直接返回

    bs, slen, n_kv_heads, head_dim = x.shape

    # 在头维度后面加一个维度
    x = x[:, :, :, None, :]  # [bs, seq_len, n_kv_heads, 1, head_dim]

    # 扩展这个维度到 n_rep
    x = x.expand(bs, slen, n_kv_heads, n_rep, head_dim)  # [bs, seq_len, n_kv_heads, n_rep, head_dim]

    # 合并 head 维度和重复维度
    x = x.reshape(bs, slen, n_kv_heads * n_rep, head_dim)  # [bs, seq_len, n_kv_heads*n_rep, head_dim]

    return x



if __name__ == "__main__":

    # 假设 batch=1, seq_len=2, n_kv_heads=2, head_dim=3
    x = torch.tensor([
        [
            [[1,2,3], [4,5,6]],    # 序列位置 0
            [[7,8,9], [10,11,12]]  # 序列位置 1
        ]
    ], dtype=torch.float)

    print("原始 x:")
    print(x)
    print("shape:", x.shape)  # [1, 2, 2, 3]

    # 重复每个 head 2 次
    n_rep = 2
    x_repeated = repeat_kv(x, n_rep)

    print("\n重复后的 x:")
    print(x_repeated)
    print("shape:", x_repeated.shape)  # [1, 2, 4, 3]

可以很明显看到结果:

Image

3、实现Attention
#

这个我们就正常的按照我们的框架图一步一步写就好了,但是这里需要补充的一点就是关于Flash Attention和Mask矩阵的说明。

对于Flash Attention来说需要PyTorch >= 2.0,一些计算的优化 显著降低自注意力(Self-Attention)的内存占用,同时加速计算。

Flash Attention 通过 “分块计算 + 逐块 softmax” 来节省内存:

分块(tiling / chunking)

  • 不一次生成完整的 $QK^T$矩阵

  • 将 Q、K、V 按小块(tile)处理,逐步计算每个块的 attention

逐块 softmax(online softmax)

  • 逐块计算 softmax,避免把整个 [seq_len, seq_len] 矩阵放到显存里

  • 使用数值稳定的累积方式计算 softmax

对于老版本,不支持的,我们手动的去计算Mask矩阵,加入进去就行。

我们看看公式是怎么操作的:

$Attention(Q,K,V) = Softmax(\frac{QK^{T} }{\sqrt{d_{k} } } + Mask)V$

就是这么暴力!为什么呢?让我们来看看Mask矩阵:

Image

大概就是长这个矩阵,来我们结合例子看看为什么这样设计:

假设原始注意力分数矩阵

应用 Causal Mask

似不似so easy!!!

来来来上代码了。每一行都写的非常细,对着框架图来,就很好写。所以,还是学会绘制框架图比较OK!!!

代码地址:第二章 动手实现/Code/Attention.py

class Attention(nn.Module):
    def __init__(self, args: ModelConfig):
        super().__init__()

        # 我们更具有多少个头的数量来确定需要多少个key 和 value的数量
        # 如果没有指定 key/value 的头数,就默认使用 query 的头数
        self.n_heads = args.n_heads
        self.n_kv_heads = args.n_kv_heads if args.n_kv_heads is not None else self.n_heads

        # 确保总头数可以被键值头数整除。
        assert args.n_heads % self.n_kv_heads == 0

        # 模型并行处理大小,默认为1。model_parallel_size=1 表示 没有分割,单卡计算如果你用多卡训练,模型会被切分成多个部分并行计算
        model_parallel_size = 1
        # 本地计算头数,等于总头数除以模型并行处理大小。
        self.n_local_heads = args.n_heads // model_parallel_size
        # 本地键值头数,等于键值头数除以模型并行处理大小。
        self.n_local_kv_heads = self.n_kv_heads // model_parallel_size
        # 重复次数,用于扩展键和值的尺寸。
        self.n_rep = self.n_local_heads // self.n_local_kv_heads
        # 每个头的维度,等于模型维度除以头的总数。
        self.head_dim = args.dim // args.n_heads

        # 开始定义qkv的权重矩阵W这个部分和传统的并未差别
        self.wq = nn.Linear(args.dim, args.n_heads * self.head_dim, bias=False)
        self.wk = nn.Linear(args.dim, args.n_kv_heads * self.head_dim, bias=False)
        self.wv = nn.Linear(args.dim, args.n_kv_heads * self.head_dim, bias=False)

        # 最后合并输出权重矩阵,这个时候其实就是说明了wq,wo他们的维度是一样的。
        self.wo = nn.Linear(args.n_heads * self.head_dim, args.dim, bias=False)

        # 定义dropout,防止过拟合,这里分为两个,一个是注意力计算过程中的,一个是输出矩阵中的
        self.attn_dropout = nn.Dropout(args.dropout)
        self.output_dropout = nn.Dropout(args.dropout)
        self.dropout = args.dropout

        # 上面我们都定义好了,开始到计算环节了,这里我们环境是满足Flash Attention
        self.flash = hasattr(torch.nn.functional, 'scaled_dot_product_attention')
        if not self.flash:
            # 若不支持Flash Attention,则使用手动实现的注意力机制,并设置mask,这个手动实现也是非常简单的
            print("WARNING: using slow attention. Flash Attention requires PyTorch >= 2.0")
            # 创建一个上三角矩阵,用于遮蔽未来信息。这个-inf就已经代表负无穷。
            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)

    # 开始真正的撸流程了,按照框架图一步一步的编写就行。
    def forward(self, x: torch.Tensor, freqs_cos: torch.Tensor, freqs_sin: torch.Tensor):
        *"""*
*        前向传播计算注意力。*
*        x: [batch_size, seq_len, dim] 输入特征*
*        freqs_cos, freqs_sin: 旋转位置嵌入(RoPE)预计算的 cos 和 sin*
*        """*

*        *# 获取输入形状
        batch_size, seq_len, dim = x.shape
        # xq/xk/xv 要求 shape: [batch, seq_len, n_heads, head_dim]

        # -----------------------------
        # 计算 Q/K/V 矩阵
        # -----------------------------
        # 通过线性变换生成查询(Q)、键(K)、值(V)
        xq, xk, xv = self.wq(x), self.wk(x), self.wv(x)

        # 调整形状以适应多头注意力
        # Q 头数 = n_local_heads
        xq = xq.reshape(batch_size, seq_len, self.n_local_heads, self.head_dim)
        # K/V 头数 = n_local_kv_heads
        xk = xk.reshape(batch_size, seq_len, self.n_local_kv_heads, self.head_dim)
        xv = xv.reshape(batch_size, seq_len, self.n_local_kv_heads, self.head_dim)

        # 应用旋转位置嵌入(RoPE)
        # 将 Q/K 的向量旋转编码位置信息,使模型可以感知序列位置
        xq, xk = apply_rotary_emb(xq, xk, freqs_cos, freqs_sin)

        # 扩展 K/V 头以匹配 Q 头
        # 当 KV 头数 < Q 头数时,需要重复 KV 头
        xk = repeat_kv(xk, self.n_rep)
        xv = repeat_kv(xv, self.n_rep)

        # 转置为 [batch, heads, seq_len, head_dim]
        # 方便矩阵乘法和注意力计算
        xq = xq.transpose(1, 2)
        xk = xk.transpose(1, 2)
        xv = xv.transpose(1, 2)

        # 计算注意力
        if self.flash:
            # 如果支持 Flash Attention(PyTorch >= 2.0),使用高效实现
            # is_causal=True 保证未来信息不可见(自回归)
            output = torch.nn.functional.scaled_dot_product_attention(
                xq, xk, xv,
                attn_mask=None,
                dropout_p=self.dropout if self.training else 0.0,
                is_causal=True
            )
        else:
            # 手动实现注意力计算(慢版本)
            # 计算注意力分数: QK^T / sqrt(head_dim)
            scores = torch.matmul(xq, xk.transpose(2, 3)) / math.sqrt(self.head_dim)
            # 添加 causal mask 遮蔽未来信息
            assert hasattr(self, 'mask')
            scores = scores + self.mask[:, :, :seq_len, :seq_len]
            # softmax 得到注意力权重
            scores = F.softmax(scores.float(), dim=-1).type_as(xq)
            # dropout 防止过拟合
            scores = self.attn_dropout(scores)
            # 加权求和得到输出
            output = torch.matmul(scores, xv)

        # 恢复时间维度并合并头
        # output shape: [batch, heads, seq_len, head_dim] -> [batch, seq_len, heads*head_dim]
        output = output.transpose(1, 2).contiguous().reshape(batch_size, seq_len, -1)

        # 输出投影回模型维度并 dropout
        output = self.wo(output)
        output = self.output_dropout(output)

        return output

我们从三张图(单个 head heatmap、平均 head heatmap、某个 query token 的条形图)是三种不同角度观察注意力分布(attention distribution)的方式。它们分别揭示模型在「注意力机制」中的不同层面的信息。

  1. 单个 head 的注意力矩阵 (plot_attention_matrix)

图像特征:

  • x 轴(横轴):Key 位置(即模型“被注意”的 token)

  • y 轴(纵轴):Query 位置(即模型“在看”的 token)

  • 颜色深浅:注意力权重大小(颜色越深,表示越关注)

含义解释:

这张图反映了:

某个注意力头(head)在不同 token 间建立了怎样的依赖关系。

每个 head 在 transformer 里通常学习到不同的“模式”:

  • 有的 head 只看「上一个 token」(语言模型常见);

  • 有的 head 看「句首」或特殊符号(比如 <BOS>、段落标记);

  • 有的 head 看「动词」或「名词」等关键位置;

  • 有的 head 看「句尾」或全局平均。

所以这张图能帮我们理解:

这个具体的 head 在学习过程中,捕捉到了怎样的结构或依赖。

举例:

假设我们输入一句话(被分成 token):

“The cat sat on the mat”

如果第 1 个 head 的图在“on”那一行,对应“the mat”列特别亮,

说明这个 head 在预测 “on” 时重点关注了 “the mat” ——

它可能在捕捉语法结构(介词短语)。

Image

  1. 平均所有 head 的注意力 (plot_avg_attention)

图像特征:

  • 同样是 heatmap(x 轴 = key,y 轴 = query)

  • 但这里的每个像素 = 所有 head 的平均注意力权重。

含义解释:

这是整个多头注意力的「总体关注模式」。

它会淡化个别 head 的差异,展示出整体趋势:

  • 若图像接近对角线 → 模型主要关注“临近 token”(局部依赖)

  • 若图像有远离对角线的亮条 → 模型存在“长程注意力”(远距依赖)

  • 若上三角暗、下三角亮 → 说明是“因果注意力”(只能看过去)

举例:

语言模型通常是“因果掩码(causal mask)”形式,

所以这张平均图的下三角会亮,对角线附近最亮,

表示每个 token 主要注意自己和之前的 token。

Image

  1. 单个 query token 的注意力分布 (plot_attention_for_query_token)

图像特征:

  • x 轴:Key 的位置(序列中所有 token)

  • y 轴:注意力权重大小(条形图高度)

含义解释:

这张图展示「某一个特定 query token」在看哪些 key 时最关注。

换句话说:

当模型在处理第 query_pos 个 token 时, 它把注意力主要放在了哪些过去的 token 上。

举例:

假设我们分析句子:

“The cat sat on the mat”

如果我们画第 5 个 token(“mat”)的注意力分布:

  • 可能看到 “the” 和 “on” 位置权重较高 → 说明模型在理解 “mat” 时依赖了 “the” 和 “on”。

  • 如果某些远处 token(比如句首 “The”)也亮 → 表示模型捕捉到了长距离依赖。

Image
if __name__ == "__main__":
    import os
    from pathlib import Path
    import numpy as np
    import matplotlib.pyplot as plt
    import torch
    import math
    import torch.nn.functional as F

    # ---------- 绘图工具 ----------
    def _to_numpy(t: torch.Tensor) -> np.ndarray:
        *"""把 tensor 转成 numpy(先 detach、cpu)。"""*
*        *if isinstance(t, torch.Tensor):
            return t.detach().cpu().numpy()
        return np.array(t)

    def plot_attention_matrix(weights, batch_idx=0, head_idx=0, title=None, show=True, fname=None):
        *"""*
*        画单个 head 的注意力矩阵(heatmap)。*
*        weights: [B, H, L, L] 的 numpy 或 torch tensor*
*        """*
*        *w = _to_numpy(weights)
        mat = w[batch_idx, head_idx]  # [L, L]
        plt.figure(figsize=(6, 5))
        plt.imshow(mat, aspect="auto")
        plt.xlabel("Key position")
        plt.ylabel("Query position")
        plt.title(title or f"Batch {batch_idx} Head {head_idx}")
        plt.colorbar()
        if fname:
            plt.savefig(fname, bbox_inches="tight")
        if show:
            plt.show()
        plt.close()

    def plot_avg_attention(weights, batch_idx=0, title=None, show=True, fname=None):
        *"""*
*        所有 head 平均后的 attention heatmap。*
*        """*
*        *w = _to_numpy(weights)
        mat = w[batch_idx].mean(axis=0)  # [L, L]
        plt.figure(figsize=(6, 5))
        plt.imshow(mat, aspect="auto")
        plt.xlabel("Key position")
        plt.ylabel("Query position")
        plt.title(title or f"Batch {batch_idx} AvgHeads")
        plt.colorbar()
        if fname:
            plt.savefig(fname, bbox_inches="tight")
        if show:
            plt.show()
        plt.close()

    def plot_attention_for_query_token(weights, query_pos, batch_idx=0, head_idx=0, show=True, fname=None):
        *"""*
*        绘制某个 query_pos 的 attention distribution(对所有 key positions)。*
*        weights: [B, H, L, L]*
*        """*
*        *w = _to_numpy(weights)
        vec = w[batch_idx, head_idx, query_pos]  # 长度 L
        plt.figure(figsize=(8, 2))
        plt.bar(np.arange(len(vec)), vec)
        plt.xlabel("Key position")
        plt.ylabel("Attention weight")
        plt.title(f"Batch {batch_idx} Head {head_idx} QueryPos {query_pos}")
        if fname:
            plt.savefig(fname, bbox_inches="tight")
        if show:
            plt.show()
        plt.close()

    # ---------- 计算 attention weights(slow path,用于可视化) ----------
    @torch.no_grad()
    def compute_attn_weights_for_vis(xq: torch.Tensor, xk: torch.Tensor, mask: torch.Tensor | None = None, head_dim: int | None = None) -> torch.Tensor:
        *"""*
*        输入:*
*            xq, xk: [B, H, L, D]*
*            mask: optional causal mask with shape broadcastable to [B, H, L, L] (can be None)*
*            head_dim: D,若为 None 则从 xq.shape[-1] 读取*
*        返回:*
*            weights: [B, H, L, L](CPU tensor,便于绘图)*
*        """*
*        *if head_dim is None:
            head_dim = xq.shape[-1]

        scores = torch.matmul(xq, xk.transpose(-2, -1)) / math.sqrt(head_dim)  # [B,H,L,L]

        if mask is not None:
            # 确保 mask 与 scores 在同一设备 / dtype,然后相加
            if mask.device != scores.device:
                mask = mask.to(scores.device)
            scores = scores + mask

        weights = F.softmax(scores.float(), dim=-1)
        return weights.detach().cpu()

    # ---------- 准备模型与输入 ----------
    args = ModelConfig(dim=64, n_heads=4, n_kv_heads=2, dropout=0.0, max_seq_len=64)
    attention_model = Attention(args)

    B = 1
    L = 20
    x = torch.rand(B, L, args.dim)

    # 你之前有 precompute_rotary_freqs 或 precompute_freqs_cis,请确保导入了名称一致的函数
    freqs_cos, freqs_sin = precompute_rotary_freqs(args.dim // args.n_heads, L)

    # 正常前向(可能使用 flash),仅获得输出(不用于可视化)
    out = attention_model(x, freqs_cos, freqs_sin)

    # ---------- 单独计算 Q/K 并算权重(slow path,仅用于可视化) ----------
    with torch.no_grad():
        # 生成线性层输出并 reshape 为 [B, L, H, D](与 forward 中相同的顺序)
        xq = attention_model.wq(x).reshape(B, L, attention_model.n_local_heads, attention_model.head_dim)
        xk = attention_model.wk(x).reshape(B, L, attention_model.n_local_kv_heads, attention_model.head_dim)

        # 应用 RoPE 与 KV 重复(与 forward 保持一致)
        xq, xk = apply_rotary_emb(xq, xk, freqs_cos, freqs_sin)
        xk = repeat_kv(xk, attention_model.n_rep)

        # 转置为 [B, H, L, D]
        xq = xq.transpose(1, 2)
        xk = xk.transpose(1, 2)

        # 可选 mask(若模型使用 slow path 时注册了 mask)
        mask = None
        if hasattr(attention_model, "mask"):
            # mask 形状为 [1,1,max_seq,max_seq],裁剪到实际长度并广播
            mask = attention_model.mask[:, :, :L, :L]

        # 计算并得到 CPU 上的权重张量 [B, H, L, L]
        weights = compute_attn_weights_for_vis(xq, xk, mask=mask, head_dim=attention_model.head_dim)

    # ---------- 保存 / 绘图 ----------
    out_dir = Path("attn_figs")
    out_dir.mkdir(parents=True, exist_ok=True)

    plot_attention_matrix(weights, batch_idx=0, head_idx=0, fname=str(out_dir / "head0.png"))
    plot_avg_attention(weights, batch_idx=0, fname=str(out_dir / "avg_heads.png"))
    plot_attention_for_query_token(weights, query_pos=3, batch_idx=0, head_idx=0, fname=str(out_dir / "query3_head0.png"))

    print(f"Saved attention figures to {out_dir.resolve()}")

前馈网络(Feed Forward Network, FFN)
#

说明:LLaMA / LLaMA-2 的 MLP 本质上是一个 gated feed-forward:先做一个线性投影到 2×hidden,分成两部分,一部分过激活函数(SiLU/GELU),再与另一部分相乘(gating),最后投影回 output_dim。常见叫法有 SwiGLU / GEGLU / GLU。下实现支持这些变体。


下面,我们仍然从数学公式开始入手:

1、数学公式
#

输入 $x \in \mathbb{R}^{B\times L \times D}$:

  1. 先线性投影:

$ [u, v] = xW_1 + b_1 \quad \text{with } [u,v]\in\mathbb{R}^{B\times L \times 2H}$

  1. 分成两块$u,v \in \mathbb{R}^{B\times L \times H}$:

  2. 计算 gated activation(以 SwiGLU 为例):

$ h = \mathrm{SiLU}(u)\odot v$

  1. 输出投影:

$ \text{out} = h W_2 + b_2$

太简单了,直接上代码
#

是不是感觉好像什么地方见过,没错,就是一个非常非常简单的多层感知机架构。

我们直接上代码:

代码地址:第二章 动手实现/Code/MLP.py

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


class LlamaMLP(nn.Module):
    *"""*
*    输入维度:dim*
*    中间维度:hidden_dim(若未指定,则自动计算为 4/3 * dim,并取 multiple_of 的倍数)*

*    结构:*
*        x -> W1 -> SiLU() 激活*
*        x -> W3 -> gating 通道*
*        二者逐元素相乘 (SwiGLU)*
*        -> W2 -> Dropout -> 输出*
*    等价于:output = Dropout(W2(SiLU(W1(x)) * W3(x)))*
*    """*

*    *def __init__(self, dim: int, hidden_dim: int = None, multiple_of: int = 256, dropout: float = 0.0):
        super().__init__()

        # 若未指定 hidden_dim,则:
        #   1. 取输入维度 dim 的 4 倍;
        #   2. 再缩小为 2/3;
        #   3. 调整为 multiple_of 的整数倍(例如 256 的倍数)。
        if hidden_dim is None:
            hidden_dim = 4 * dim
            hidden_dim = int(2 * hidden_dim / 3)
            hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)

        # --------------------------------------------
        # 定义线性层
        # --------------------------------------------
        # W1:主通道(经过 SiLU 激活)
        self.fc_gate = nn.Linear(dim, hidden_dim, bias=False)
        # W3:门控通道(直接相乘)
        self.fc_up = nn.Linear(dim, hidden_dim, bias=False)
        # W2:输出投影层(回到原始维度)
        self.fc_down = nn.Linear(hidden_dim, dim, bias=False)

        # Dropout 防止过拟合
        self.dropout = nn.Dropout(dropout)

    def forward(self, x: torch.Tensor) -> torch.Tensor:
        *"""*
*        前向传播:*
*        Args:*
*            x: [batch_size, seq_len, dim]*
*        Returns:*
*            output: [batch_size, seq_len, dim]*
*        """*
*        *# 主通道经过 SiLU 激活
        gated = F.silu(self.fc_gate(x))  # [B, L, hidden_dim]

        # 门控通道(线性变换)
        up = self.fc_up(x)  # [B, L, hidden_dim]

        # SwiGLU 核心:逐元素相乘
        hidden = gated * up  # [B, L, hidden_dim]

        # 输出投影 + Dropout
        output = self.fc_down(hidden)
        output = self.dropout(output)

        return output


# -------------------------------------------------
# 测试示例
# -------------------------------------------------
if __name__ == "__main__":
    args = ModelConfig()

    # 创建 MLP 模块实例
    mlp = LlamaMLP(
        dim=args.dim,
        hidden_dim=args.hidden_dim,
        multiple_of=args.multiple_of,
        dropout=args.dropout
    )

    # 随机输入 [batch=1, seq_len=50, dim]
    x = torch.randn(1, 50, args.dim)

    # 前向计算
    output = mlp(x)

    print(f"输入形状: {x.shape}")
    print(f"输出形状: {output.shape}")

Decoder Layer
#

回顾整体框架图
#

Image

我们回顾一下整个网络架构的图,到目前为止,基本所有的组件,我们都一步一步的实现好了。现在就到我们开始组装整个模型了,但是我们还有一步需要完成,就是可以重复的嵌套的Decoder层。这个就比较简单了,因为Decoder层的输入输出的维度都是一样的,所以,只需一点点技巧就可以完整的实现。

下面我们就开始着手开始组装。

组装Decoder层
#

代码地址:Code/DecoderLayer.py

import torch.nn.functional as F
import math
import torch
from torch import nn
from ModelConfig import ModelConfig
from Attention import Attention
from MLP import LlamaMLP
from RMSNorm import RMSNorm
from ROPE import *

class DecoderLayer(nn.Module):
    def __init__(self, layer_id: int, args: ModelConfig):
        super().__init__()
        # 下面我们按照框架图一步一步来就行
        # 定义输入特征维度
        self.dim = args.dim
        # 定义有几个多头注意力头
        self.n_heads = args.n_heads
        # 定义每个头的维度,等于输入维度除以头数,这个要千万注意
        self.head_dim = args.dim // args.n_heads
        # 定义注意力机制
        self.attention = Attention(args)
        # 定义FFN
        self.FFN = LlamaMLP(
            dim=args.dim,
            hidden_dim=args.hidden_dim,
            multiple_of=args.multiple_of,
            dropout=args.dropout,
        )

        # 设置层id
        self.layer_id = layer_id
        # 定义注意力计算的归一化层
        self.attention_norm = RMSNorm(args.dim, eps=args.norm_eps)
        # 定义前馈神经网络计算的归一化层
        self.ffn_norm = RMSNorm(args.dim, eps=args.norm_eps)

    def forward(self, x: torch.Tensor, freqs_cos: torch.Tensor, freqs_sin: torch.Tensor) -> torch.Tensor:
        *"""*
*        单层解码器前向(pre-norm 风格)*
*        Args:*
*            x: [batch_size, seq_len, dim] 输入张量(残差输入)*
*            freqs_cos, freqs_sin: RoPE 预计算的 cos / sin,shape 与 Attention 要求一致*
*        Returns:*
*            out: [batch_size, seq_len, dim] 经过本层处理后的输出(已加残差)*
*        """*
*        *# 先对输入做 RMSNorm(归一化),再传入 Attention 计算
        # 注意:attention 接受的是原始 x 的归一化版本(x_norm)
        # Shapes:
        #   x              -> [B, L, D]
        #   x_norm         -> [B, L, D]
        x_norm = self.attention_norm(x)

        # attention 返回 [B, L, D]
        attn_out = self.attention(x_norm, freqs_cos, freqs_sin)

        # 残差连接:将 attention 的输出加回到原始输入
        x = x + attn_out

        # 对残差后的 x 做 RMSNorm,再输入到 FFN(LlamaMLP)
        # Shapes:
        #   x             -> [B, L, D]
        #   x_ffn_norm    -> [B, L, D]
        x_ffn_norm = self.ffn_norm(x)

        # FFN 返回 [B, L, D]
        ffn_out = self.FFN(x_ffn_norm)

        # 残差连接:将 FFN 的输出加回
        x = x + ffn_out

        # 返回本层最终输出
        return x




if __name__ == "__main__":
    args = ModelConfig()
    decoderlayer = DecoderLayer(0, args)
    # 模拟输入数据
    dim = args.dim
    seq_len = 50

    x = torch.randn(1, seq_len, dim)  # [bs, seq_len, dim]

    freqs_cos, freqs_sin = precompute_rotary_freqs(dim // args.n_heads, seq_len)

    out = decoderlayer(x, freqs_cos, freqs_sin)

    print(out.shape)  # 形状和输入的x一样 [batch_size, seq_len, dim]

到此基本的都搭建完成!!!

等等,是不是还有一个问题,ModelConfig这个是什么玩意?

ModelConfig
#

ModelConfig作为全局统一配置的参数。这里详细的介绍一下。

代码地址:第二章 动手实现/Code/ModelConfig.py

from transformers import PretrainedConfig


class ModelConfig(PretrainedConfig):
    model_type = "Tiny-K"

    def __init__(self,
                 dim: int = 384,           # 模型维度
                 n_layers: int = 6,       # Transformer层数
                 n_heads: int = 8,        # 多头
                 n_kv_heads: int = 4,      # kv数量
                 vocab_size: int = 6144,   # 词汇表大小
                 hidden_dim: int = None,   # 隐藏层大小
                 multiple_of: int = 64,    # 隐藏层对齐数
                 norm_eps: float = 1e-5,   # 归一化层的平滑项
                 max_seq_len: int = 256,   # 最大序列长度
                 dropout: float = 0.0,
                 flash_attn: bool = True,  # 是否使用Flash Attention
                 **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.dropout = dropout
        self.flash_attn = flash_attn
        super().__init__(**kwargs)

dim: int = 384

  • 模型的隐藏维度(embedding dimension / model width),通常记为 D。

  • 影响:Embedding 参数量(vocab_size × dim)、所有线性层的输入/输出维度、每层计算量和显存。

n_layers: int = 6

  • Transformer 层数(stacked decoder/encoder 层数)。

  • 影响:模型深度、参数量(层数越多参数越多)、推理/训练时间。

n_heads: int = 8

  • Query 的头数(总 head 数)。

  • 影响:每个 head 的维度 head_dim = dim // n_heads(因此要能整除),query/projection 的矩阵形状、并行度。

n_kv_heads: int = 4

  • key/value 的头数(可以与 n_heads 不同)。有些实现为了节省计算/参数,KV 头数少于 Q 头数(然后通过 repeat_kv 把 KV 复制到 Q 的头数)。

  • 影响:如果 n_kv_heads < n_heads,需要额外逻辑(像你前面的 repeat_kv),并且要保证 n_heads % n_kv_heads == 0(常见约束),否则头重复次数不是整数,会出问题。

vocab_size: int = 6144

  • 词表大小,影响 embedding 矩阵大小(vocab_size × dim),直接影响显存与参数量。

hidden_dim: int = None

  • MLP 中间层维度(通常 4 * dim4/3 * dim,取决实现)。

  • None 表示让模型在运行时或构造时计算默认值(你的代码里只是保存了 None;建议在模型内部或 config 初始化时计算并保存最终值)。

multiple_of: int = 64

  • 用于把 hidden_dim 向上对齐到 multiple_of 的倍数(这在 LLaMA/一些实现里用于内核/张量对齐以提高性能)。

  • 举例:如果 hidden_dim 计算结果为 2458,multiple_of=64,则会向上取到 64 * ceil(2458/64)

norm_eps: float = 1e-5

  • LayerNorm / RMSNorm 的 eps 值,用于数值稳定性(避免除以 0)。

  • 小数值常见:1e-51e-6 等。

max_seq_len: int = 256

  • 模型能处理的最大序列长度,常用于预构造 causal mask 或 RoPE 的频率表长度等。

  • 注意:如果输入序列超过这个长度且代码没做动态处理,会报错或截断。

dropout: float = 0.0

  • dropout 概率(训练时),用于 MLP 或 attention 的 dropout。

  • 在大型语言模型训练/推理阶段常为 0(或小值)。

flash_attn: bool = True

  • 指示是否优先使用 Flash Attention(PyTorch >= 2.0 的 scaled_dot_product_attention 或其他高效 kernel)。

  • 如果 True,在支持的环境会选择 faster kernel;如果 False,走慢实现以便获取权重或兼容旧版本。

**kwargs

  • 任何额外传入的参数都会被 PretrainedConfig 捕获并纳入序列化/反序列化(这也是 HuggingFace 生态常用的做法,便于扩展字段而不破坏兼容性)。

上面的参数配置,已经压缩都非常非常小了。属于是ok单人套餐了。

下面就开始实现完整的LLaMA2模型。

手撸完整LLaMA2模型
#

我们依然对着完整的框架图:

框架图稍微变动了一下,最后输出采用CausalLMOutputWithPast。本质上和softmax没有什么区别,但是它有很多可以扩展的东西可以选择:

预测的 logits

  • 模型输出的原始未归一化概率分数(即 softmax 前的向量)。

  • 形状通常为 [batch_size, seq_len, vocab_size]

  • 用于生成下一个 token 的概率或计算损失。

past_key_values(可选)

  • 对于多层 Transformer,保存每一层的注意力缓存(Key/Value)。

  • 形状大致为 [layer][2][batch, num_heads, seq_len, head_dim]

  • 作用:在生成长序列时无需重新计算历史上下文,提高推理效率(尤其在 autoregressive decoding 中)。

hidden_states(可选)

  • 每一层 Transformer 的隐藏状态。

  • 用于分析或微调模型中间层特征。

attentions(可选)

  • 每一层的注意力权重(可用于可视化)。

  • 形状 [batch, num_heads, seq_len, seq_len]

代码地址:第二章 动手实现/Code/LLaMA2.py

from typing import Optional

import math
import torch
from torch import nn
from transformers import PreTrainedModel
from transformers.modeling_outputs import CausalLMOutputWithPast
import torch.nn.functional as F

from ModelConfig import ModelConfig
from DecoderLayer import DecoderLayer
from RMSNorm import RMSNorm
from ROPE import *


class Transformer(PreTrainedModel):
    *"""*
*        自回归 Transformer 模型(类似 GPT/LLaMA 架构)*
*        - 支持多层 Decoder*
*        - 使用 Rotary Positional Embedding*
*        - 可共享词嵌入与输出投影*
*        - 输出 CausalLMOutputWithPast 类型*
*    """*
*    *# 注入配置类
    config_class = ModelConfig
    # 记录最后一次计算的损失
    last_loss: Optional[torch.Tensor]

    def __init__(self, args: ModelConfig):
        super().__init__(args)
        # 初始化模型参数
        self.args = args
        # 词汇表大小,这个大小必须是和之前我们训练的Tokenizer里面一样,不然id会错乱
        self.vocab_size = args.vocab_size
        # 层数
        self.n_layers = args.n_layers

        # 词嵌入层,这一步是将我们id变为向量的形式,这里可以联想一下独热编码
        self.tok_embeddings = nn.Embedding(args.vocab_size, args.dim)
        # Dropout层
        self.dropout = nn.Dropout(args.dropout)

        # Decoder层,这里通过短短的三行代码就实现对Decoder层的遍历嵌套
        self.layers = torch.nn.ModuleList()
        for layer_id in range(args.n_layers):
            self.layers.append(DecoderLayer(layer_id, args))

        # 归一化层
        self.rmsnorm = RMSNorm(args.dim, eps=args.norm_eps)

        # 输出层
        self.output = nn.Linear(args.dim, args.vocab_size, bias=False)

        # 将词嵌入层的权重与输出层的权重共享
        self.tok_embeddings.weight = self.output.weight

        # 相对位置嵌入的频率
        freqs_cos, freqs_sin = precompute_rotary_freqs(self.args.dim // self.args.n_heads, self.args.max_seq_len)
        self.register_buffer("freqs_cos", freqs_cos, persistent=False)
        self.register_buffer("freqs_sin", freqs_sin, persistent=False)

        # 初始化所有权重
        self.apply(self._init_weights)
        # 对残差投影进行特殊的缩放初始化
        for pn, p in self.named_parameters():
            if pn.endswith('w3.weight') or pn.endswith('wo.weight'):
                torch.nn.init.normal_(p, mean=0.0, std=0.02 / math.sqrt(2 * args.n_layers))

        # 初始化最后一次前向传播的损失属性
        self.last_loss = None
        self.OUT = CausalLMOutputWithPast()  # 输出容器
        self._no_split_modules = [name for name, _ in self.named_modules()]  # 不分割的模块列表

    def _init_weights(self, module):
        # 初始化权重的函数
        if isinstance(module, nn.Linear):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)
            if module.bias is not None:
                torch.nn.init.zeros_(module.bias)
        elif isinstance(module, nn.Embedding):
            torch.nn.init.normal_(module.weight, mean=0.0, std=0.02)



    def forward(self, tokens: torch.Tensor, targets: Optional[torch.Tensor] = None, **kwargs) -> torch.Tensor:
        *"""*
*        - tokens: Optional[torch.Tensor], 输入 token 张量。*
*        - targets: Optional[torch.Tensor], 目标 token 张量。*
*        - kv_cache: bool, 是否使用键值缓存。*
*        - kwargs: 其他关键字参数。*

*        - self.OUT: CausalLMOutputWithPast, 包含 logits 和损失。*
*        """*

*        *# 处理输入别名
        if 'input_ids' in kwargs:
            tokens = kwargs['input_ids']
        if 'attention_mask' in kwargs:
            targets = kwargs['attention_mask']

        # 前向传播函数
        _bsz, seqlen = tokens.shape
        # 通过词嵌入层和Dropout层
        h = self.tok_embeddings(tokens)
        h = self.dropout(h)
        # 获取相对位置嵌入的频率
        freqs_cos = self.freqs_cos[:seqlen]
        freqs_sin = self.freqs_sin[:seqlen]

        # 通过Decoder层
        for layer in self.layers:
            h = layer(h, freqs_cos, freqs_sin)
        # 通过归一化层
        h = self.rmsnorm(h)

        if targets is not None:
            # 如果给定了目标,计算损失
            logits = self.output(h)
            self.last_loss = F.cross_entropy(logits.view(-1, logits.size(-1)), targets.view(-1), ignore_index=0,
                                             reduction='none')
        else:
            # 推理时的小优化:只对最后一个位置的输出进行前向传播
            logits = self.output(h[:, [-1], :])
            self.last_loss = None

        # 设置输出
        self.OUT.__setitem__('logits', logits)
        self.OUT.__setitem__('last_loss', self.last_loss)
        return self.OUT

    @torch.inference_mode()
    def generate(self, idx, stop_id=None, max_new_tokens=256, temperature=1.0, top_k=None):
        *"""*
*        逐步生成序列(采样版本)*
*            - idx: [batch_size, seq_len] 输入序列*
*            - stop_id: 停止 token id*
*            - max_new_tokens: 最大生成长度*
*            - temperature: 采样温度*
*            - top_k: top-k 采样*
*        """*
*        *index = idx.shape[1]
        for _ in range(max_new_tokens):
            # 如果序列上下文过长,截断它到最大长度
            idx_cond = idx if idx.size(1) <= self.args.max_seq_len else idx[:, -self.args.max_seq_len:]

            # 前向传播获取序列中最后一个位置的 logits
            logits = self(idx_cond).logits
            logits = logits[:, -1, :]  # 只保留最后一个时间步的输出

            if temperature == 0.0:
                # 选择最有可能的索引
                _, idx_next = torch.topk(logits, k=1, dim=-1)
            else:
                # 缩放 logits 并应用 softmax
                logits = logits / temperature
                if top_k is not None:
                    v, _ = torch.topk(logits, min(top_k, logits.size(-1)))
                    logits[logits < v[:, [-1]]] = -float('Inf')
                probs = F.softmax(logits, dim=-1)
                idx_next = torch.multinomial(probs, num_samples=1)

            if idx_next == stop_id:
                break

            # 将采样的索引添加到序列中并继续
            idx = torch.cat((idx, idx_next), dim=1)

        # 只返回生成的token
        return idx[:, index:]



if __name__ == "__main__":

    def count_parameters(model):
        *"""*
*        统计模型中可训练参数的数量*

*        Args:*
*            model: PyTorch模型*

*        Returns:*
*            int: 可训练参数总数*
*        """*
*        *return sum(p.numel() for p in model.parameters() if p.requires_grad)

    args = ModelConfig()
    # LLaMA2Model.forward 接受两个参数,tokens和targets,其中tokens是输入的张量, 应为int类型
    x = torch.randint(0, 6144, (64, 256))  # [bs, seq_len]
    # 实例化LLaMA2Model
    model = Transformer(args=args)
    # 计算model的全部参数
    print(f'LLM总参数量:{count_parameters(model) / 1e6:.3f} 百万')

    out = model(x)
    print(out.logits.shape)  # [batch_size, 1, vocab_size]

完结,撒花!!!

到此,完整的网络框架搭建完成。

后续就是更为重要的预训练过程与SFT训练。

相关文章