
深度揭秘!DeepSeek-R1如何打破AI界限?惊艳性能竟与巨头比肩!
- Rifx.Online
- Machine Learning , Open Source , Generative AI
- 10 Feb, 2025
人工智能中的“顿悟”时刻
在本文中,我们将讨论DeepSeek-R1,这是第一个展现出与闭源LLM(如Google、OpenAI和Anthropic等公司生产的模型)相当性能的开源模型。这一性能的提升是人工智能领域的一个重要里程碑,也是DeepSeek-R1成为热门话题的原因。
我们将首先简要介绍DeepSeek所基于的一些基本机器学习思想,然后描述一些新颖的训练策略,这些策略使DeepSeek-R1超越了其他开源LLM。我们将花费相当多的时间深入探讨“群体相对策略优化”,这是DeepSeek用来提升其推理能力的主要来源,也是其在性能上超越其他开源模型的主要原因。
在对DeepSeek-R1有了透彻的概念理解后,我们将讨论如何将大型DeepSeek-R1模型提炼成更小的模型。我们将下载其中一个较小的DeepSeek模型,并使用它在消费级硬件上进行推理。最后,我们将以对DeepSeek未来如何影响人工智能技术发展的推测作为结束。
到本文结束时,您将理解DeepSeek是什么,它是如何创建的,如何使用,以及它将对行业产生的影响。
这对谁有用? 任何想要全面理解人工智能前沿技术的人。
这篇文章的难度如何? 本文包含对前沿人工智能概念的直观描述,适合各个层次的读者。
前提条件: 无
什么是 Deep Seek,为什么它很重要?
你打开这篇文章的原因可能是因为互联网对 DeepSeek-R1 感到非常兴奋,这是中国人工智能初创公司 DeepSeek 发布的一个新模型。
DeepSeek 公司于 2023 年 11 月 2 日通过开放源代码和宽松许可发布了多种模型,其中 DeepSeek-R1 就是其中之一。DeepSeek 还发布了论文 “DeepSeek-R1: Incentivizing Reasoning Capability in LLMs via Reinforcement Learning”,其中包括基准结果,表明 DeepSeek-R1 在许多需要复杂推理的任务中与 OpenAI 的 o1 模型相当。这使得 DeepSeek-R1 备受关注,因为它是第一个实现这种性能水平的开放源代码和透明文档语言模型。
在我们深入论文之前,我想介绍一些与我们探索相关的人工智能概念。这些是我认为在形成对 DeepSeek 完整理解时有用的主题的简要描述。如果你对这些概念不需要复习,可以随意跳过基础部分。
基础 1) 训练
从根本上讲,AI模型可以被概念化为一个可以调整的巨大旋钮盒,以便在特定任务上表现得更好。例如,在训练语言模型时,你可能会给模型一个问题。然后,你使用模型内的旋钮生成一些答案,最初这些答案相当糟糕。
接着,你更新旋钮,使得期望的输出更有可能
经过数百万甚至数十亿个例子后,你会得到一个在回答问题方面表现不错的模型。如果你想更好地理解这个一般过程,可以查看我关于神经网络的文章。
DeepSeek 使用这种一般方法的精细化系统来创建具有更强推理能力的模型,我们将对此进行深入探讨。
基础 2) 转换器
转换器是人工智能中的一个关键架构,是几乎所有前沿人工智能模型(包括 DeepSeek)的基本骨架。我认为理解转换器的细节并不是至关重要,但如果你感兴趣,我确实写了一篇关于这个主题的文章。
基本上,转换器是一个上下文化模型,专门用于比较和组合信息。例如,如果你将一系列单词中的每个单词表示为一个向量,你可以将其输入到转换器中。然后,转换器将输出一个复杂的数据集合,以某种抽象的方式表示整个输入。这个抽象的上下文化数据集合用于创建更高阶的推理能力。
通过创建和推理这些复杂的数据组合,转换器能够完成一些几年前甚至被认为不可能的复杂任务。转换器的发现在很大程度上推动了我们今天所看到的人工智能的爆炸性增长。
基础 3) 生成式预训练变换器 (GPT)
最初的变换器作为一种开源研究模型发布,专门用于英语到法语的翻译。真正将这一架构推向聚光灯的是OpenAI的“生成式预训练变换器”(简称GPT,如同ChatGPT)。
虽然最初的变换器设计有两个部分,一个用于接收输入,另一个用于生成输出
但GPT将所有内容整合成一个统一的模型,从本质上讲,将输入和输出视为相同。
由于GPT没有输入和输出的概念,而是直接接收文本并生成更多文本,因此可以在来自互联网的任意数据上进行训练。在随后的研究中,OpenAI发现,当这一架构在更多数据和更大参数量的基础上进行扩展时,可以实现前所未有的能力。
这种建模风格随后被称为“仅解码器变换器”,并且仍然是大多数大型语言和多模态模型的基本方法。DeepSeek就是一种仅解码器风格的变换器。
有关更多信息,可以参考以下内容:
Foundation 4) 思维链
在像GPT这样的模型普及不久后,研究人员和普通用户都开始尝试有趣的提示策略。其中一种策略被称为“思维链”。
基本上,您首先提示模型思考答案,然后再提供答案,而不是直接提示模型给出答案。
这是一种微妙但极其强大的技术。变换器一次生成一个单词,使用之前的单词来生成未来的单词。思维链使模型能够生成使最终生成最终答案更容易的单词。
我有一篇文章讨论思维链,以及一些其他类似的方法:
DeepSeek-R1的一个主要特征是它在思维链之上使用了一种强大的训练策略,以增强其推理能力,我们将对此进行深入讨论。
Foundation 5) 低秩适应 (LoRA)
有些人可能会对我为什么将LoRA包含在这个基本概念的列表中感到困惑。它与DeepSeek本身没有直接关系,但它确实有一个强大的基本概念,这将在我们后面讨论“蒸馏”时相关。
随着变换器的发展,能够非常出色地完成许多任务,“微调”的概念变得越来越流行。
微调的核心问题是,如果某个语言模型知道一些东西,我如何让它了解我的东西。如果研究人员制作了一个以某种方式交谈的模型,我如何让这个模型以我想要的方式交谈?本质上,我如何让一个大型通用模型按照我的需求为我的应用程序行动。
为了实现这一点,您基本上是再次训练模型。一些拥有强大计算机的研究人员训练了一个大型语言模型,然后您在自己的数据上对该模型进行一点点训练,以使模型的行为更符合您的期望。这很好,但存在一个大问题:训练大型AI模型既昂贵又困难,且耗时,“只需在您的数据上训练它”说起来容易,做起来难。
“低秩适应”(LoRA)解决了微调的问题,并大大减轻了这些问题,使训练更快、计算需求更低、更简单且对数据的需求更少。他们这样做的方式是基于一个证明非常正确且极具威力的假设。
像变换器这样的AI模型本质上由称为参数的大型数据数组组成,这些参数可以在训练过程中进行调整,以使它们在特定任务上表现得更好。
LoRA团队假设这些参数对于学习过程非常有用,使模型能够在训练过程中探索各种推理形式。然而,一旦模型实际训练完成,AI模型中包含了大量重复信息。
LoRA论文的作者假设,您可以用相对少量的参数来更新模型,然后扩展这些参数以修改模型中的所有参数。在高级AI术语中,这被称为“低秩适应”。
结果证明这个假设是正确的。您可以用不到1%用于实际训练模型的参数来微调模型,并仍然获得合理的结果。
然而,值得注意的是,已经持续证明大型模型在实际训练时更好,这正是GPT和OpenAI爆炸性增长背后的整个理念。用大量数据和大量参数训练的模型通常更好。
这两个看似矛盾的事实导致了一个有趣的见解:大量参数对于模型在训练过程中以不同方式推理问题的灵活性至关重要,但一旦模型训练完成,参数中就存在大量重复信息。我们稍后会重新讨论这一点为何对模型蒸馏重要。
欢迎查看这篇文章以获取更多信息:
不过,现在让我们深入探讨DeepSeek。
DeepSeek模型系列的基本理念
论文“DeepSeek-R1: 通过强化学习激励LLM的推理能力”点燃了所有的兴奋,因此我们将在本文中主要探讨这个主题。我将把它称为“DeepSeek-R1论文”,其中引用了多种模型。
- DeepSeek-V3-Base
- DeepSeek-R1-Zero
- DeepSeek-R1
- 基于Llama的1.5B、7B、14B和70B参数的蒸馏模型
- 基于Qewn的8B和32B参数的蒸馏模型
让我们了解一下这些模型各自的内容。
DeepSeek-V3-Base
我们在本文中不会深入讨论 DeepSeek-V3-Base,它本身值得讨论,但目前我们可以将 DeepSeek-V3-Base 理解为一个大型的变换器(6710 亿可训练参数),它是在高质量文本数据上以典型的方式进行训练的。因此,您从互联网获取一些数据,将其分成两半,输入模型的开头部分,然后让模型生成预测。接着,您将模型的预测与实际跟随的数据进行比较,并更新模型,使其更有可能预测实际输出。
您在一个大型模型和数百万美元的计算集群上对大量数据执行此操作,结果就是您拥有了一个现代的 LLM。当然,DeepSeek-V3-Base 采用了一些有趣的细微之处,但它本质上只是一个标准的仅解码器变换器,如 Llama、Mixtral、GPT 等。
DeepSeek-R1-Zero
这就是事情变得有趣的地方。
DeepSeek-R1-Zero 本质上是 DeepSeek-V3-Base,但通过一种叫做“强化学习”的精巧过程进行了进一步训练。我们将在整篇文章中深入探讨这一点,但基本思想是这样的:
- 他们给 DeepSeek 提供了两个特殊的输出,
<think>
和</think>
,允许模型在产生最终输出之前进行独立思考。 - 然后,他们给模型提出了一堆逻辑问题,比如数学问题。研究人员并不知道解决这些问题的最佳方法是什么,但他们可以判断答案是正确还是错误。
- 当 DeepSeek 很好地回答问题时,他们使模型更有可能产生类似的输出;当 DeepSeek 差劲地回答问题时,他们使模型不太可能产生类似的输出。
这被称为“强化学习”,因为你通过训练模型在输出被认为好的时候更有信心,从而强化了模型的良好结果。
经过一段时间的这个过程,他们发现取得了非常好的结果,远远超过了可比的开源模型。但他们也注意到了一些问题。由于模型本质上是基于自己之前的推理过程来形成自己的推理过程,因此它发展出了一些被强化的怪癖。它会随机切换语言,产生人类无法比拟的输出,并且经常无休止地重复某些内容。为了处理这些问题,他们创建了 DeepSeek-R1。
DeepSeek-R1
在强化学习中有一个笑话:“你的初始化是一个超参数”。基本上,因为强化学习倾向于加倍关注某些思维方式,你使用的初始模型可以对这种强化产生巨大影响。
这通常被视为一个问题,但DeepSeek-R1利用了这一点。DeepSeek背后的团队创建了一个高质量思维链示例的数据集,来源于各种渠道:
来自DeepSeek-R1-Zero的好示例他们通过(手动或使用某些AI系统)查看DeepSeek-r1-zero的思维和回应,找到了特别好的模型思考过程并提供高质量答案的示例。
直接提示以获取详细答案他们通过在提示中使用“深入思考”和“仔细检查你的工作”等短语来提示DeepSeek-r1-zero,使其产生高质量输出。
带有长思维链的少样本提示他们在模型输入中提供了他们希望的思维链类型的示例,希望模型在生成新输出时能够模仿这些思维链。这是一种通常被称为“上下文学习”的流行策略。
通过这些方法,他们收集了数千个DeepSeek-R1-zero创造高质量思维和行动的示例,然后明确地对这些示例进行了DeepSeek-V3-Base的微调。你可以认为这是在调整DeepSeek-V3-Base,使其更符合人类对DeepSeek-R1-zero推理过程的喜好。
他们利用这些数据对DeepSeek-V3-Base进行了高质量思维的训练,然后通过另一轮强化学习对模型进行了训练,这与创建DeepSeek-r1-zero的过程相似,但使用了更多的数据(我们稍后会详细介绍整个训练流程的具体情况)。这创造了DeepSeek-R1,其性能超越了所有其他开源LLM,达到了与OpenAI的o1模型相当的水平。
换句话说,在DeepSeek-r1-zero中,强化学习直接应用于DeepSeek-V3-Base。而在DeepSeek-r1中,他们首先对DeepSeek-V3-Base进行了高质量思维的微调,然后用强化学习进行了训练。
蒸馏到 Llama 和 Qewn 基础模型
一旦 DeepSeek-r1 被创建,他们生成了 800,000 个模型推理的样本,通过各种问题,然后利用这些示例对不同规模的开源模型进行微调。Llama 是 Meta 创建的一系列开源模型,Qewn 是阿里巴巴创建的一系列开源模型。这样做的目的是将 DeepSeek-R1 强大的推理能力赋予其他较小的开源模型。
所以,这就是总体情况。DeepSeek 的工程师们采用了一个相当普通的 LLM(DeepSeek-v3-Base),并使用一种叫做“强化学习”的过程来提高模型的推理能力(DeepSeek-r1-zero)。然后,他们收集了良好推理的示例,基于这些示例对 V3-Base 进行了微调,然后再次进行了强化学习(DeepSeek-r1)。接着,他们使用该模型创建了一批训练数据,以训练较小的模型(Llama 和 Qewn 蒸馏模型)。
现在我们对发生了什么有了一个模糊的大致了解,让我们深入一些具体细节。
强化学习
在性能提升、Deep Seek 中最具新意的想法以及 DeepSeek 论文中最复杂的概念都围绕着强化学习展开。
正如之前在基础部分讨论的那样,训练模型的主要方式是给它一些输入,让它预测一些输出,然后调整模型中的参数,使该输出更可能。
这被称为“监督学习”,其特征是准确知道你想要的输出是什么,然后调整输出使其更相似。
然而,在某些问题中,可能不确定输出应是什么。例如,我们可能希望我们的语言模型解决某个复杂的数学问题,虽然我们知道答案,但并不确切知道它应该使用什么思路来回答这个问题。我们知道模型在最终结果方面表现得好或不好,但对于使我们达到该结果的思维过程中的好与不好并不确定。
这就是强化学习发挥作用的地方。强化学习在最简单的意义上假设,如果你得到了一个好的结果,那么导致该结果的整个事件序列都是好的。如果你得到了一个不好的结果,那么整个序列就是坏的。由于 AI 模型输出概率,当模型产生一个好的结果时,我们会尝试让所有产生该结果的预测更加自信。当模型产生一个不好的结果时,我们可以让这些输出的自信程度降低。
DeepSeek 团队使用了许多已知答案的数学问题、科学问题、编码问题、文本格式问题和其他问题的例子。他们让模型思考这些问题以生成答案,查看这些答案,并使模型在其答案准确的预测中更加自信。
所以就这样。哇。为什么还有这么多文章?
好吧,强化学习的概念相当简单,但这种方法有许多需要适应的陷阱。为了解决这些问题,DeepSeek 团队创建了一种称为“组相对策略优化(GRPO)”的强化学习算法。
为什么存在群体相对策略优化 (GRPO)
我计划写一篇关于强化学习的综合文章,深入探讨更多的术语和概念。不过,对于这篇文章,我认为GRPO可以根据其自身的优点来描述,而不是通过一堆繁重的理论工作来阐述。
让我们先来看看为什么GRPO存在。简单的强化学习存在一些大问题:
- 偏离良好性: 如果你使用强化学习训练一个模型,它可能会学会在奇怪且潜在有问题的输出上加倍努力。这本质上是AI的“下兔子洞”现象,遵循一系列合乎逻辑的步骤,直到它最终陷入无意义的状态。想象一个推理模型通过强化学习发现“然而”这个词可以更好地进行推理,因此在面对一个无法解决的困难问题时,它开始不停地说“然而”。处理此类问题的一种流行方法称为“信任区域策略优化”(TRPO),而GRPO则借鉴了其中的一些思想。
- 低效的性能估计: 我们不会深入探讨这个问题,但强化学习的一个问题是,有时在采取行动和获得奖励之间会有延迟。例如在国际象棋中,牺牲一个棋子可能会让你赢得比赛,因此如果奖励仅仅是两位玩家之间的相对材料,这种策略可能会在简单的强化学习方法下受到抑制。一种常见的解决方案是使用“价值模型”,该模型学习观察你尝试解决的问题,并输出一个更好的奖励近似值,以便你可以在其上训练你的模型。这很好,但这意味着你需要训练另一个(通常大小相似的)模型,而在训练后你就会将其抛弃。
- 样本低效: 一旦你在强化学习上训练了一个模型,该模型就会发生变化,这意味着它与您尝试解决的问题的交互方式也会改变。因此,在进行了一些强化学习后,你必须让你的模型再次与问题进行交互。然后你再训练一会儿,再与问题交互。训练、与问题交互。这种在训练过程中不断需要重新运行问题的需求可能会为训练过程增加显著的时间和成本。
在涵盖了这些一般性思想后,让我们深入探讨GRPO。
GRPO的数学
这是“组相对策略优化”(GRPO),它的光辉。
我知道这看起来像很多数学,确实如此,但一旦你拆解开来,它意外地简单。
首先,GRPO是一个目标函数,意味着其核心目的是让这个数字上升。如果我们做到这一点,那就意味着模型在变得更好。
这是一个关于ϴ(theta)的函数,表示我们希望通过强化学习训练的AI模型的参数。因此,考虑到我们模型的当前参数,我们希望J_GRPO上升。如果我们做到这一点,我们就是在用GRPO进行强化学习。
数学的实际结构有点复杂。它们提供了一个理论表达式,表示用于GRPO的数据,以及一个更详细的表示。
抽象表示以字符“E”开头,表示“期望值”,这意味着我们将基于一些数据计算某个平均值。这样做的目的是详细说明我们将要操作的数据,而不是我们将要进行的确切操作。
期望值中有两个元素。
表达式q∼P(Q)
意味着我们将从所有查询中随机抽取查询。因此,我们有一些数学和科学问题的数据集(P(Q)
),我们将随机抽取示例(q
)。这里,~
表示“从中抽取”。
另一个表达式,蓝色突出显示,包含一些需要澄清的字符。在这里,πθ
表示我们将要训练的模型,而πθold
表示模型的一个特定版本。
从高层次来看,GRPO是一种迭代方法。我们进行一段时间的GRPO,然后在我们的问题数据集上测试我们的新模型。我们再次进行GRPO,再次测试我们的模型。因此,根据我们在这个过程中的位置,有各种版本的πθ
。
在整个GRPO表达式中,有三个版本的πθ
(模型)被引用:
πθ
表示模型现在的状态,因此是经过微调后最新的一组参数。πθold
表示上一轮GRPO的结果模型。因此,这是用于对数据进行最近一轮测试的模型版本。πθref
是我们在进行任何GRPO迭代之前的模型。因此,这是我们在首次开始GRPO过程时使用的参数。
回到理论表达式
我们将从所有问题P(Q)
中抽取一些问题q
,然后将问题传递给πθold
,因为它是一个AI模型,AI模型处理概率,因此该模型对于给定的q
能够产生广泛的输出,这表示为πθold(O|q)
。这表示πθold
理论上可以给出一系列值O
,给定特定问题q
。带有大括号的表达式
意味着我们将从可能的输出空间中抽取G
个特定输出。因此{o1, o2, o3, o4, ... , oG}
,其中o表示旧模型在给定查询q
时的某个输出。
因此,以一种幽默复杂的方式,这个表达式表示“我们将计算某个函数的平均值。该函数将接受一些随机问题,并将通过同一模型对该问题的几个不同示例的输出进行计算”。
实际计算的函数是表达式的其余部分。这由实际的GRPO表达式组成,依赖于两个其他子表达式。
这些子表达式被称为“KL散度”(蓝色突出显示)和“优势”(红色突出显示)。让我们先讨论优势。
所以,回忆一下我们在做什么。我们通过训练模型在有“好答案”时更自信来强化模型的能力。“优势”就是我们定义好答案的方式。
回忆一下,我们正在处理同一查询的同一模型的多个输出。
第一步是将这些输出传递通过奖励函数
奖励函数可以是任意复杂的。你甚至可以让一个人坐下来并说“这个答案很好,这个答案不好”。对于DeepSeek,他们主要使用数学、编码和科学问题,在这些问题中他们已经知道答案。使用这种类型的数据,我们可以简单地将模型的输出与已知答案进行比较(可以是自动进行或使用LLM)以生成一些数值奖励。例如,你给模型一个问题“反转链表”,如果它生成的代码可以运行,就给它0.1的奖励;如果模型正确回答问题,就给它0.3的奖励;如果结果比90%的人类编码者的答案更快,就给它0.7的奖励,等等。
因此,我们从模型中获得了一组奖励。接下来,我们使用这些奖励来计算一个优势。
第i
个输出的“优势”是第i
个输出的奖励,减去所有输出的平均奖励,然后除以所有输出奖励的标准差。
你可能在想“好吧,当然”或者“你刚才说了什么”,这取决于你是否上过概率和统计课。为了避免过于深入,基本上,我们将所有的奖励视为一个钟形曲线。
然后我们观察某个特定示例的某个特定奖励在这个钟形曲线上的位置。
对于那些奖励高于平均值的示例,它们将具有正优势。
对于那些奖励低于平均值的示例,它们将具有负优势。
基于结果与其他结果的比较来计算“优势”的这个想法在GRPO中至关重要,这就是为什么这种方法被称为“组相对策略优化”。我们在说“这是一个特别好或坏的输出,基于它相对于所有其他输出的表现”。
现在我们已经计算出所有输出的优势,我们可以用它来计算GRPO函数的主要部分。
具体来说,我们可以计算这个表达式。
这是从概念上看GRPO优势函数的主要部分。如果这个数字很大,对于给定的输出,训练策略会在模型中强烈强化该输出。
这个表达式围绕这个比率展开。回忆一下,在训练过程中我们持有多个版本的模型:
πθ
表示模型现在的状态,因此是我们当前强化学习进展所产生的最新一组参数。πθold
表示上一轮GRPO的结果模型。因此,这是用于对数据进行最近一轮测试的模型版本,并生成了输出oi
。πref
是我们在进行任何GRPO迭代之前的模型。因此,这是我们在首次开始GRPO过程时使用的参数。
我们使用πθold
来回答我们的问题,然后我们使用GRPO来更新πθ
,它最初与πθold
相同,但在用GRPO训练模型的过程中,模型πθ
将变得越来越不同。因此,基于πθold
的输出来训练πθ
在我们进行训练的过程中变得越来越不合理。
我关于推测采样的文章详细介绍了变换器风格模型如何输出概率
但,基本上:
我们可以让当前模型πθ
预测它认为某个输出的可能性,并将其与πθold
在输出我们正在训练的答案时的概率进行比较。
然后,我们可以使用这些概率的比率来近似这两个模型之间的相似性。如果新旧模型输出相似的结果,那么它们可能非常相似,因此我们根据该示例的优势全力训练。如果旧模型的概率远高于新模型,那么这个比率的结果将接近零,从而缩小该示例的优势。如果你有兴趣深入了解这个概念,它源于一种叫做“近端策略优化”(PPO)的技术,我将在未来的文章中介绍。近端优化的整个要点是试图约束强化学习,以使其不会过于偏离原始模型。
其余的表达式实际上是为了塑造这个概念的特征,以便在我们旧模型和新模型的所有可能相对值中更有意义。
所以首先,我们取这两个表达式的最小值。
我们讨论了蓝色的那个,但让我们花一点时间思考它真正表达了什么。
如果新模型对旧模型的信心更高,蓝色的表达式会放大 Ai。这可能有些道理(一个响应更好,而模型对此非常有信心,这可能是一个不寻常的好答案),但一个核心思想是,我们基于 πθold
的输出来优化 πθ
,因此我们不应该偏离 πθold
太远。具有可以扩展到任意大值的优势意味着整个目标函数可能会爆炸到任意大值,这意味着强化学习可能会迅速远离旧版本的模型。
黄色高亮的部分限制了“1-ε”和“1+ε”之间的比率,约束了两个模型输出的比率对优势的影响。在这里,“ε”是一些参数,数据科学家可以调整它以控制从 πθold
偏离的探索程度。
考虑这两个表达式的最小值之间的关系是值得的,因为这正是 GRPO 的主要部分。
如果优势为负(某个输出的奖励远低于所有其他输出),而且如果新模型对该输出的信心非常高,这将导致一个非常大的负数,可以在最小函数中未剪辑地通过。因此,如果新模型对糟糕的答案的信心比旧模型生成这些答案时的信心更高,目标函数将变为负数,这用于训练模型以大幅降低此类输出的激励。
如果某个输出的优势很高,而旧模型对该输出的信心远高于新模型,那么奖励函数几乎不受影响。在训练新模型时,这不会受到大幅降低激励的影响,也不会受到大幅增强的影响。
如果优势很高,而新模型对该输出的信心远高于前一个模型,则允许其增长,但可能会根据“ε”的大小进行剪辑。这意味着模型被允许对旧模型不太自信的想法变得更有信心,但新模型被限制不偏离旧模型太远。
这个过程可以在多个迭代中对旧模型生成的相同输出进行迭代。因为新模型被限制为与生成输出的模型相似,所以输出在训练新模型时应该是合理相关的。
一旦新模型与旧模型有足够的不同,我们可能需要再次将新模型暴露于我们的提问中。此时它将成为旧模型,我们可能会进行另一个基于它的强化学习轮次。
请记住,强化学习的一个问题是样本效率低下。让 LLM 生成答案是昂贵的,因此为每次强化学习的迭代创建新答案是成本 prohibitive。通过使用这种策略,我们可以在整个更大强化学习过程中多次对相同数据强化我们的模型。这是可能的,因为当我们强化 πθ
时,我们限制它与 πθold
相似,这意味着我们的输出 oi
对 πθ
仍然相关,即使 πθold
被用来生成输出 oi
。
好吧,我们快完成了,但还有一个数学概念。“KL 散度”。
KL 散度是两个概率分布之间的标准“距离单位”。这里有一些复杂的数学原因解释为什么它是以这种确切的方式写的,但我认为在这篇文章中深入讨论并不值得。我更愿意采取图形方法。
回想一下,我们有我们的参考模型 πref
,它是我们在进行任何 GRPO 迭代之前的模型。因此,πθ
是当前正在训练的模型,πθold
是上一轮的模型,并用于生成当前批次的输出,而 πref
代表我们在进行任何强化学习之前的模型(本质上,该模型仅使用传统的监督学习方法进行训练)。让我们为几个不同的 πref(oi|q)
和 πθ(oi|q)
值绘制这个 DKL 函数,看看我们得到什么。
在这里,我写出了 KL 散度的表达式,并给出了我们参考模型输出的一些值,并展示了多个 πθ
输出的散度。正如你所看到的,当 πθ
偏离参考模型的输出时,KL 散度增加。
如果你像我一样喜欢图表,可以将其视为一个表面,当 πθ
偏离 πref
时,我们会得到高值的 KL 散度。
我们正在从之前计算的所有内容中减去 KL 散度。因此,如果 KL 散度很大,这将对整体目标产生负面影响。
这意味着,我们不仅限制我们的训练不偏离 πθold
,我们还限制我们的训练不偏离 πref
,即在我们进行任何强化学习之前的模型。我们通过 β 来缩放 KL 散度的影响,这是一个数据科学家可以用来调整此约束影响力的超参数。
整个 GRPO 函数具有一个称为“可微分性”的属性。基本上,我们希望整体奖励 JGRPO
更大,因为该函数是可微分的,我们知道对 πθ
的哪些调整将导致更大的 JGRPO
值。因此,我们可以调整模型中的参数,使 JGRPO
的值稍微增大。
这会导致不同的 πθ
值,因此我们可以检查是否有一些新的调整是合理的,以基于 JGRPO
函数使 πθ
增大,并应用这些变化。我们可以一次又一次地做到这一点,以修改我们的模型 πθ
的参数,使其更有信心于好的输出,而不偏离 πθold
或 πref
太远。
此时你的脑袋可能会感到晕眩。让我们放大一点,看看这在更大的训练流程中是如何实际运作的。
训练流程概述
现在我们对DeepSeek的工作原理有了一定的了解,我想从更整体的角度回顾训练的各个步骤、使用的数据类型以及采用的高层次训练方法。
首先,DeepSeek的研究人员从一个大型现代LLM,DeepSeek-V3-Base开始,该模型是一个仅解码的变换器风格模型,训练于互联网规模的数据上。
然后,他们将DeepSeek-V3-Base与一些特殊输出<think></think>
和<answer></answer>
结合,这样模型可以学习使用这些输出以鼓励推理后再作出回应。然而,仅仅因为你将这些特殊输出添加到模型中,并不意味着模型知道如何使用它们。教会模型这样做是通过强化学习完成的。
他们使用带有这些特殊标记的DeepSeek-V3-Base,并采用GRPO风格的强化学习来训练模型完成编程任务、数学任务、科学任务以及其他相对容易判断答案正确与否但需要一定推理能力的任务。最终结果是DeepSeek-R1-Zero。
DeepSeek-R1-Zero在不可读的思维过程、语言混合和其他问题上表现出了一些问题。DeepSeek团队利用强化学习在初始状态上高度依赖的事实,针对DeepSeek-R1-Zero的高质量人工注释输出以及其他采购的高质量思维链示例,对DeepSeek-V3-Base进行了微调。
然后,他们进行了几种其他的训练方法,我稍后会详细介绍,比如尝试使模型与人类偏好对齐、注入纯推理以外的数据等。这些方法与我们之前讨论的训练策略相似,但根据DeepSeek-R1-Zero的不足之处增加了一些细微差别。这个新模型被称为DeepSeek-R1,正是大家所关注的对象。
然后,他们使用DeepSeek-R1生成了80万个训练示例,这些示例被用来直接训练一系列较小的模型。研究表明,这些较小的开源模型从学习模仿DeepSeek-R1的推理能力中受益。
一些细节
就这些,简而言之。我们讨论了 GRPO、一般方法以及 DeepSeek 论文中的大部分主要思想。在我们开始使用 DeepSeek 之前,我想探讨一些具体细节。
Specific 1) 信用分配问题
“信用分配问题”是强化学习中最大的一个问题,如果不是最大的一个问题的话,而相对策略优化(GRPO)作为一种强化学习形式,继承了这个问题。
问题本质上是,如何定义一个输出为好?或者,更正式地基于数学,如何将奖励分配给一个输出,以便我们可以使用多个输出的相对奖励来计算优势,并知道该强化什么?
他们做的最简单的事情是选择一些容易检查的问题,正如我们之前讨论的那样。可能有一些已知的数学问题,比如“计算 f(x)=sqrt(tan(x))dx 的积分并展示所有步骤”,其答案是已知的。在模型思考完这个问题后,他们可以简单地通过程序检查答案是否正确,并用这个来分配一些奖励。
他们还为正确的格式给予了小奖励。值得注意的是,研究人员不想在模型中施加过多的显式结构,因为他们希望强化过程能够鼓励围绕逻辑推理的自发策略,但他们确实强制执行了一种结构,如 <think> ... </think><answer> ... </answer>
。
他们还实验了两阶段奖励和语言一致性奖励,这受到 DeepSeek-r1-zero 的失败启发。在两阶段奖励中,他们基本上将最终奖励分成两个子奖励,一个是模型是否得到了正确答案,另一个是模型是否有一个合理的推理结构,即使输出中有或没有错误。他们使用 LLM(DeepSeek-V3) 来判断推理过程的完整性和逻辑一致性,并强化那些被 LLM 认为结构良好、逻辑清晰并包含格式良好的推理的输出。这是有用的,因为,特别是在强化学习的早期阶段,模型可能并不擅长实际获得最终奖励,但更全面和高质量的逻辑想法可能是一个好的中介目标,以引导模型朝向最终目标。
他们在语言一致性奖励方面也做了类似的事情。如果模型在整个输出中保持了一致的语言,并且与所问问题的语言一致,模型会获得小奖励。有趣的是,这实际上稍微降低了模型的性能,但更符合人类的偏好。
Specific 2) A Further Breakdown of training DeepSeek-r1
在整个训练流程的高层概述中,我提到了一些训练过程的具体细节。现在我想详细说明这些细节。DeepSeek-r1的整个训练过程大致如下:
我们已经对早期的部分有了完整的理解。我们从DeepSeek-V3-Base开始,添加特殊标记以允许模型分离思维和响应,并在高质量思维链的示例上对该模型进行微调。然后我们使用群体相对策略优化(GRPO)应用强化学习。
不过,在这一点之后,训练中还采用了一些其他步骤。其中一种训练策略是拒绝采样。
基本上,在第一次强化学习之后,模型已经吸收了我们训练它的推理方法,进行了优化,并自主生成了许多推理示例。拒绝采样的想法是查看那些生成的示例,并使用规则和LLM评估的组合来过滤出好的和坏的响应。然后,我们可以再次基于这些高质量生成的响应对模型进行微调,以明确训练模型模仿它们。我们基本上是使用强化学习生成一堆逻辑推理的示例,然后仅使用最佳示例来使用更传统的监督学习方法明确微调模型。
在实际使用这些数据训练模型之前,他们注入了几种不同类型的问题,而不仅仅是纯数学和科学。在这一点上,模型一直在内省分析纯科学、数学和编码,研究人员希望鼓励更一般的能力,因此他们还将问答、写作和翻译任务注入到这些数据中。模型微调的数据集大约包含60万个拒绝采样的推理数据示例,结合20万个高质量的一般任务示例,总共800k个高质量示例。这些数据用于再次对模型进行微调。
然后,他们进行了另一轮强化学习,但这次使用了基于人类偏好的规则和偏好模型的组合,以尝试让模型生成高质量且人类可读的输出。因此,这与上一次使用GRPO的强化学习轮次非常相似,只是在对每个输出给予的奖励中添加了与人类偏好一致的奖励。
具体 3) 蒸馏
回想一下,我们进行蒸馏的方法是训练 DeepSeek-R1,一个拥有 670 亿参数的模型,然后使用这个大型模型生成训练数据,接着用这些数据来微调 15 亿到 700 亿参数的开源模型。训练大型模型的成本更高,那么为什么不跳过训练 DeepSeek-R1,而是将用于训练 DeepSeek-R1 的策略直接应用于这些较小的模型呢?换句话说,为什么要经历训练一个大模型并蒸馏到较小模型的麻烦,而我们可以直接在较小模型上采用相同的策略?
有趣的是,DeepSeek 背后的研究人员尝试过这种方法,但结果并不理想。从较大模型蒸馏出来的小模型的性能优于直接训练的小模型。
这与为什么 LoRA 在本文的基础部分中出现有关。越来越多的证据表明,AI 模型的大参数数量在训练中是非常重要的。要正确学习复杂的事物,似乎需要有“额外的空间”来探索问题。但是,一旦模型训练完成,这些参数就不那么重要了。这可能支持从较大模型蒸馏的性能优于从头开始训练较小模型的原因。
玩转 DeepSeek
这大约只有 7.5K 字,适合轻松的下午阅读。你肯定有精力看看一堆代码。让我们加载其中一个 DeepSeek 的开源版本,并在本地进行实验。
幸运的是,由于模型托管在 huggingface 上,这应该是一个相当简单的过程。在这里,我使用的是 Google Colab 中的 A100 GPU,但由于有许多蒸馏版本,你可以在使用的特定硬件上进行大量实验。
首先,我们需要加载模型。在这里,我使用的是 Qwen-7B 变种。
import torch
from transformers import AutoModelForCausalLM, AutoTokenizer
## Specify the model name
model_name = "deepseek-ai/DeepSeek-R1-Distill-Qwen-7B"
## Load the tokenizer
tokenizer = AutoTokenizer.from_pretrained(model_name)
## Load the model
model = AutoModelForCausalLM.from_pretrained(model_name)
## Check if a GPU is available and use it if possible
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model.to(device)
然后,我们可以让模型生成一些推理。在这里,我设置 skip_special_token=false
,以便在输出中查看我们的思维和响应的特殊标记。
## Define your input prompt
prompt = "explain how to revers a linked list"
## Tokenize the input prompt
input_ids = tokenizer.encode(prompt, return_tensors="pt").to(device)
## Generate text
with torch.no_grad():
output_ids = model.generate(
input_ids,
max_length=10000, # Adjust the max_length as needed
num_return_sequences=1,
no_repeat_ngram_size=2,
temperature=0.7,
)
## Decode the generated text
generated_text = tokenizer.decode(output_ids[0], skip_special_tokens=False)
## Print the generated text
print(generated_text)
这是完整的输出。
<|begin▁of▁sentence|>explain how to revers a linked list
<think>
Okay, so I need to figure out how someone would explain reversing a singly linked linked. Hmm, I remember that linked lists consist of nodes, each containing data and a pointer to the next node. Reversing it would mean pointing each node to its previous node instead of the forward direction.
First, maybe I should think about the steps involved. I know that you can't just reverse the pointers directly because you lose the original head. So, perhaps I'll need a new head after reversing. Let me sketch it out.
Starting with the head, let's say it's node A pointing to B, which points to C, and so on. After reversing, the new order would be C -> B -> A. The new tail would become A, but since we need the reversed list, we can set the final head to be the last node, C. Wait, no, that's not right. Actually, when you reverse, A was the first node and becomes the tail, while C becomes head.
Wait, hold on, if the list is A -> null, reversing it should be null <- A or just A as a single node? No, wait. If the initial list has more nodes like A->B->C->null, then reversing would make C->A-> null? Wait no. No. When reversing the linked-list, you need each previous pointer of each current node's next to point to it. Maybe I'm getting confused.
Let me think step by step. Suppose I have a list: 1 -> 2 ->3 ->4. To reverse it, it becomes 4->3->2->1. How is that achieved?
I think I can approach this by changing the links of consecutive nodes. But I also need variables to keep track of what's before and after each step.
Maybe I start with three pointers: previous, current, next.
Initialize previous as null.
Take current as the starting head (1).
Then, for each iteration, set next as current.next (which is 3 initially).
So, current is pointing at 5? Sorry, in my example, initial nodes are 0,1,2,3.
So step by step:
First iteration:
current is head:1.
next is current's next:2.
We need current to now point back to previous (null), but wait, previous is null. Then, after that, move previous to current (previous = previous.next =1), then current= next =2. Now, head is previous's new value.
Second iteration: current = next, say at node2 now.
current's previous becomes previous=1 (so node1's prev is node0?), wait no.
I'm not sure. Perhaps a better way is:
Initially, prev = null; current refers to head node (A). next is the node after current.
In each loop:
- save next of current: next_node = current -> next.
- current points back: set current->next to prev.
Wait no: because in the loop, moving the current forward would require that current now points backward. Or wait.
Alternatively, to reverse a node:
For each non-null node:
- node.next becomes node.prev
- But we have to manage the prev pointers as we move along.
But since nodes don't have prev attributes, because it was a forward list. Oh wait wait! If it is a doubly linked, this is easy, as each has a prev and next. However, since it’s singly, how do we do this?
So perhaps in a singingly linked approach, during each reversal step, once you change the pointer, keep updating the previous and current variables.
Hmm. For example:
Initialize:
prev = None
current = head
next_node initially is None.
Then in each while loop iteration:
1) Save the reference to next so that when current changes, there's a reference.
2) Change current pointers to refer to what was prev. Because prev was before current; now current should point before prev, making the sequence reverse.
3) Then move prev to become current. prev becomes current and then we step current.
4) current becomes next (the original next after the two nodes).
Wait maybe this can be done in steps:
prev initially null
curr = first_node
while curr is not null:
next_curr = curr.next
curr pointing from curr to curr.prev (but since prev hasn't been updated, curr's original prev may not be correct)
Wait this seems confusing.
Perhaps another approach is to use three variables:
Let’s say we're at a current_node. It has next which is next in line. We can think of it as:
original: prev_node -> current Node -> forward_node
We want to change it so it points:
forward_node <- current <- prev
So to do that:
1.) prev's pointer is set to null (since in reversed order, forward node is now the start)
2.) current pointer now should now link to forward.
3.) then, process the rest.
No, not exactly. Since we are moving through each link, need an iterative approach.
Another way: take the nodes and reverse their pointers step-by-step.
First node: A points null.
Second node B points A.
Third node C points B.
Fourth node D points C.
To reverse:
We start at A: need A to have D as next? Or no—wait, reversed linked would have A point from D.
Actually, correct reversed sequence would point:
D -> C <- B <-A.
Therefore, D's.next is C; C's.prev is B; B's(prev isA; A's isnull.
Hence, steps to achieve this:
Idea is for a given current head:
Set the curr node.
Set a previous head variable.
While current isn't null:
nextNode =current.next
current.prev = previous
Wait but again, nodes in singly have no prev pointer.
Ah, right, sorry—singly, they don’t have the 'prev' attribute. Therefore, pointers are only 'next'.
Therefore approach:
Use the three pointer method.
prev, present, future.
1st: Initialize prev as None, pointing before the link.
2nd: For each Node in list:
a. Remember the Next node before changing anything.
b. Change the present node’s next pointer from next present to previously previous.
So present.next would now be prev.
c. Move prev forward to present.
d. present moves to future node
Repeat until all nodes processed.
Yes, okay, now let me try to simulate this.
Example: nodes A(1)->B(2)->C(3)->D(4)->null
Initialize prev= None
curr= A (head)
loop:
next = A.next= B
A.next set as prev (None)
prev becomes A
then curr becomes B.
Now, loop again:
curr=B, its next= C
B.next=A (prev)
Set prev=B
Then curr=C
Loop again: curr=3
next=C.next=D
C's .next=B (now prev)
set prev=C,_curr=D.
Loop: D is curr.
D.next=C.
C becomes prev now (C).
curr becomes null because D.next was C and we set curr=D, whose next was set in prev step? Not sure, may need another iteration.
After curr = D:
Loop:
Check if D != null: yes.
Save next=D.next=null.
then set D . next prev which was B (because in previous steps, B was previous).
Set D’s .prev=B?
Wait wait—no, actually, using the variables, here:
In the step where curr was D, before processing:
Next was null?
Yes.
Because the D node was pointing next null after being set.
Thus, step for D:
next= D-> next=null
Set curr D to D -> prev: which would refer back.
Earlier, 'previous' was nodeC.
When curr=B:
After setting B.next to A.
previous was A after curr=A.
curr=C:
C.next=B.
Previous isB.
Curr=D:
set D. next=B? Because previous wasB?
No wait: in that case, sequence after processing D would change.
This is getting confusing. Need to write it down.
Alternative: Use a while-loop.
Here's the correct stepwise approach for reversing:
Function reverseList(head):
prevNode <- NULL
currentNode <- head
While currentNode is NOT NULL:
Step 6: Save nextnode before modifying the pointing:
NextNode := currentNode.next()
Step7: currentNode's pointers now need their next set correctly.
Since in reverse list currentNode should come after prevnode, currentNode points prev node?
But in our case:
When currentNode was head A:
A's current next becomes NULL (as prev-node is NULL). But in reality, nodeA would need its prev set, i.e., the A should have its 'pre' as nodeB, etc.
Hold on—maybe a different approach. Using a dummy head and tail.
Create a Dummy node which will help in handling the dummy node as head or tail accordingly.
Dummy node has prev pointing null and curr as NULL.
As we traverse the main list with a curr pointer:
At each curr:
new_prev = Dummy.next // which starts as dummy
dummy.next.prev would set dummy's right to new_prev.
new_ptr = dummy.next.next // original curr
Dummy.next's curr would link new_ptr to dummy next's
This might be complicated.
An alternative method is using three nodes: pointer before, pointer current which needs to move, point next for the following node in chain.
At every step,
- Save reference of next
prev -> curr ->next
prev's -> becomes curr (after reversing)
curr's <- becomes None (or prev)
next's-> becomescurr
But because its a loop until next == null
Let's try this with example.
Initial state: head=A, linked as A→B→C→D→null. Reverse it to get D→ C→ B→ A → null
Let prev be None initially.
Current is start node:A.
Next node of A is: B. Next is saved as B before any changes.
Step1: Reverse A and B:
Make A’s prev point null→ but that’s already the case.
A’s current prev would still be B? Hmm no...
Wait in this approach: we’re trying to make the order B<-A, with A being the same.
Not sure.
Alternate approach with code-like pseudocode:
function reverseSingleLinkedList(head)
if head == NULL || head.next ==NULL
return head # already reversed or single-node
end if
// create a sentinel node
sentinel = new Node
// sentinel's links are dummy
preV = sentinel
p = preV
q = p.next # head of list
r = q.next
// step through list and link
do
q.prev=p ## make q point backward to sentinel
### move sentinels pointers
sententail =q
p = r
q= r.next
r= q's.?
### but how?
Alternatively:
Another approach—using three-pointer technique:
The idea is:
At any step in reversing:
We have three consecutive elements: x → y → z
After reversing: z ← y ← x
To do so, first, z's link should go to y. y's should link back, x's remain.
How?
We can have:
y_prev → x ←y →z →next.
Reverse:
z←y ←x ←z.next?
Hmm, getting stuck.
Looking up the standard way to implement this: the iterative method with prev、current、next pointers.
The standard steps are:
reverseHead = prev
While (current != NULL):
Save currentNext = Current.next node.
current pointing backward: Current.prev node = Pre.
Update prev:
Pre =Current
Current = Next
So:
So in code:
pre = NULL
cur = node
nextNode= node
while cur !=NULL:
temp = cur.next()
cur.prev() =pre
Wait actually in singly linked node there is no 'prec' pointer. Each node only has 'Next' field. Ah, yes, my mistake.
Oops, correction: each singly node does not have 'Prev'—only ' Next '.
That complicates things.
If that is true, can we still reverse?
Because in order to modify pointers, without a ' prev ' pointer in node structure, is it possible? Well, by using variables like ' pre ' and ' cur ', we might reconstruct the reverse.
Each time, a cur node points previous by setting its Next to pre.
And then update pre and cur accordingly in loop.
For example: Let's have variables pre, cur, temp.
Initially:
cur is first head.
pre is none.
temp =cur.next().
cur's Next =Pre (none). So first step: after reversal, pre is cur.
pre becomes cur.
cur becomes temp (next node).
Loop until cur is empty.
Reversal step:
pre's becomes new current
so:
Wait let’s think.
With pre initially as none,cur as first.
- temp is stored as cur's old next
- cur’s Next points now to none (pre was none).
- pre becomes now cur
cur then becomes old temp
In next iteration,
pre points cur (from first iteration)
cur points temp, old second node,
so temp was second's former next third node...
Then temp now is third.
so, third's prior next would to second.
thus, second’s now next points pre (first node), third points second, hence the chain is reversed.
Proceeding step-wise.
Simulate this on example A B C D → to reversed D C B A
Initial:
head is D? no initial head was.
Assuming head =A
pre= none
First cur =head=A
temp=cur->Next()=B
set cur-> Next= pre → A becomes pointing nowhere.
set pre= cur → pre=A now
update cur= temp → cur=B now
Loop continues:
temp=B->_next() C
set B-> prev=A. B next=C
Wait: no in step above.
Pre is initially None. In first loop: cur=A points next B → temp=B. Set A Next as pre(None). Pre becomesA. cur becomesB. Loop continues.
Temp is c.
B's Previous is pre(A). B Next=C. Pre is updated toB now. Cur isC next step?
In third iteration of loop:
cur=C; temp=C->None?
Check:
Pre=pre (B). cur=C: temp= c->none.
Cur's (c) next being B is what?
Set C.next as Pre(B).
Pre becomes C (cur). Cur becomes T, where T is temp which could be D?
Hold no:
Because after third step (if current=C):
temp=C next() isD.
Setting C next(pre=B).
Pre becomes C (cur). Cur becomes D now temp()?
Fourth iteration (D):
D's temp next()? D next couldbe null if last.
Check: pre=C,
cur= d.
d.next()? null or if longer list.
Regardless, setting d's pointing back:
d->pre=c.
Update pre=d,
cur=temp= null → loop exits.
Result: list becomes D <- C <-> B <->A → D
Yes that makes sense.
Implementing this in pseudocodes:
Thus:
ReverseList function:
If head null return null else:
create two pointers pre =null and
current= head
loop while current not Null:
save temp=current.next.
if temp exists:
then current → next should become pre
But current’s previous would’ve been pre. but current doesn’t track prev; so in current setup, only use the pre variable to set this correctly. Thus:
Current.next →pre.
else:
do nothing as it has no next
pre=current.
current=temp
end loop
return pre as newHead.
Testing this logic with our example (nodes A,B,C,D):
Initial:head= a
loop1:
temp=b
a.next→ pre(null). pre=a, cure becomesb.
loop2:
cur=b,pre=a
temp=c
b.next=a (set to a)
pre=b. cure=c
temp=c'snext=d
c.next=b
pre=c. current=d
temp=d'sNext: null
set c.next pre which isi c's now pre was c, c is cure
No change, cause c pointing b.
c's pre: c
current becomesd'stemp: d.next:?
d is then processed:
loop3:
current=c: wait loop3 is when cure is d?
Let see:
first loop1 set cure to b, done.
second loop2 set cur to c: cure =c.
third loop step3: process c:
save temp as d
set c’snext aspre which ise b?
pre isc. so c→b
then pre updates to cur c →c, becomes pre now c?
current updates tod
fourth loop4:
process current d:
if d has temp null...
d’s temp=null
d→next becomespre c (d becomes c←d)
then pre updated tod, cured becomesnull (temp isd. temp.next? null)
Loop ends.
return head whichis pre's first value. pre after loop ispre=d.
Which is correct, our reversed head would bed.
Sequence becomes d→c→ b→ a→ null as desired.
Great.
Similarly, test with single nodes:
say node a.
reverse:
head=none, enter loop? pre=null, curve=a.
temp=a.next null;
set a.next-pre(null).
pre set tO a, Cure set null loop ends. Return a ashead. Correct.
Test with empty list—returns null correctly as per function.
Okay. Hence, implement it in Python.
Python code using this three variable approach.
</think>
以下是逐步反转单链表的解释:
#### 解释:
要反转单链表,请按照以下步骤迭代执行:
#### **初始化:**
- 开始时有两个指针变量:`pre`(指向当前节点之前的节点)和指向初始头节点的 `curr`。
#### **遍历和重新指向:**
1.
**保存引用:** 对于当前节点:
- 存储其 `next` 指针的地址(保存在 `temp` 中)。
2.
**将当前节点指向前一个节点:** 让
`当前` 节点的 `Next` 指向 `Pre`。
更新前一个节点:将 prev
移动到 Curr
以进行下一次迭代。
移动当前节点:通过将 Cur
移动到之前保存的 Temp
节点来沿着列表前进,以便进行进一步处理。
重复步骤,直到 Cure
达到 null
。
为什么这样有效:
每个节点遍历整个列表一次,有效地反转它们的连接。通过更新每个 node
的 Previous
并推进遍历,最终,所有 nodes
以相反的顺序链接在一起。
Python 代码:
class Node(object):
def __init__(self, data):
self.data = data
# Initialize the "next" pointer for singly LinkedList
def next(self):
pass
class LinkedList(object): #[Assumed]
def reverseHead(self,head):
if not head:
return None
preNode,CurrNode=self.Node(None), self.Node(head.data)
while not CurrNode.next:
tempNode=Curr_node.next
CcurrNode.prev=prevNode
pre_node=curr_node
Curnode=tempNode
示例代码:
反转单链表的Python代码
Node 类
class Node:
def __ init__( self, val ): self. val = val self.next = None
LinkedList 类
class LinkedList:
'''反转头 ''' head = None
reverse_Head( self): if self.head is null : return
# 创建哨兵节点
pre = Node( None )
sent =Node(None )
pre.next sent current node sent.next selfhead prev = sent.
while node is not null :
temp node . .Next node . Next . prev prev next ( node ) node temp
return prev . val
代码首先创建一个节点的辅助类,具有 `val` 属性和链接。添加了一个哨兵节点来处理反转的开始。`遍历节点`,将 `每个节点` 重新指向 `前一个节点`,并向前推进直到结束。最后,返回现在反转后的头节点的值。
</>
#### 示例代码执行:
**输入:** 链表节点 `1-> ->2 →3 →
**反转:** `3 → ←2 →1 →null`
**输出:** 返回第一个反转节点的值 `->` 我认为返回的是数据。
**注意:** 此处提供的代码是概念性的,可能需要根据具体实现细节进行调整。<|end▁of▁sentence|>
```
你可能在想“哇……为了反转一个链表输出这么多内容”,你是对的。在提供响应之前它思考了很久。如果我们查看响应并抓取所有在 `</think>` 之后的文本,表示响应的结束,我们得到
```python
这是逐步反转单链表的解释:
#### 说明:
要反转单链表,请按照以下步骤进行迭代:
#### **初始化:**
- 从两个指针变量开始:`pre`(指向当前节点之前的节点)和`curr`指向最初的头节点。
#### **遍历和重新指向:**
1.
**保存引用:** 对于当前节点:
- 存储其`next`指针的地址(保存在`temp`中)。
2.
**将当前节点指向前一个节点:** 使
当前节点的`Next`指向`Pre`。
```
current -> previous
```
3.
更新前一个节点:将`prev`移动到`Curr`以进行下一次迭代。
4.
移动当前节点:通过将其移动到之前`Temp`保存的节点,向列表前进以进行进一步处理。
5.
重复步骤,直到`Cure`达到`null`。
#### 为什么这样有效:
每个节点在整个列表中迭代一次,有效地反转了它们的连接。通过更新每个`node`的`Previous`并推进遍历,最终,所有`nodes`以相反的顺序链接在一起。
#### **Python 代码:**
```python
class Node(object):
def __init__(self, data):
self.data = data
# 初始化单链表的“下一个”指针
def next(self):
pass
class LinkedList(object): #[假设]
def reverseHead(self,head):
if not head:
return None
preNode,CurrNode=self.Node(None), self.Node(head.data)
while not CurrNode.next:
tempNode=Curr_node.next
CcurrNode.prev=prevNode
pre_node=curr_node
Curnode=tempNode
```
#### 示例代码:
```Python
## Python 代码以反转单链表
## 节点类
class Node:
def __ init__( self, val ):
self. val = val
self.next = None
## 链表类
class LinkedList:
'''反转头 '''
head = None
reverse_Head( self):
if self.head is null :
return
# 创建哨兵节点
pre = Node( None )
sent =Node(None )
pre.next sent
current node sent.next selfhead
prev = sent.
while node is not null :
temp node . .Next
node . Next . prev
prev next ( node )
node temp
return prev . val
代码首先创建一个节点的帮助类,具有 val
属性和链接。添加一个哨兵节点以处理反转的开始。循环遍历节点
,将 每个节点
重新指向 前一个节点
,并向前推进直到结束。最后,返回现在反转后的头节点的值。
</>
示例代码执行:
输入: 带有节点的链表 `1-> ->2 →3 →
反转: 3 → ←2 →1 →null
输出: 返回第一个反转节点的值 ->
我认为返回数据。
注意: 此处提供的代码是概念性的,可能需要根据具体实现细节进行调整。<|end▁of▁sentence|>
我的想法
首先,我认为人们对DeepSeek的反应是合理的。这是开源AI向前迈出的重要一步,因为在不久的将来,它可能与闭源AI一样,甚至更具能力。此外,它能够在几乎任何硬件上通过蒸馏运行,这无疑为许多有趣的用例打开了大门。尽管如此,我确实认为一些炒作有点过于夸大。
Deep Seek论文描述了通过强化学习发现的涌现思维过程,特别引人注目,引用了模型在其思维过程中趋向于产生“恍然大悟”时刻的具体趋势。
通过亲自观察DeepSeek的输出,我发现该模型具有质疑自身、突出自身思维过程中的关键问题以及识别推理过程中可以构建的关键里程碑的引人注目的能力,这无疑令人兴奋。然而,我也发现该模型在探索相对琐碎的任务时思考得很多。
这并不是DeepSeek公司第一次发表论文。在之前的论文《DeepSeekMath: Pushing the Limits of Mathematical Reasoning in Open Language Models》中,他们讨论了为什么GRPO和强化学习总体上能提高语言模型性能的看法。
本质上,在这篇论文中,他们认为像GRPO这样的强化策略并没有使模型在根本上更强大,然而它们通过允许模型在提供响应之前深入思考一个想法,使模型更具鲁棒性。如果你熟悉集成方法,这在逻辑上是相似的。
本质上,我将其比作让模型在找到答案之前在问题上踌躇一段时间。这确实使输出更为稳健,但代价是推理的音量增加。
在DeepSeek-R1论文中,他们展示了在强化学习过程中,模型的思考过程长度有增加的趋势。
我认为可以合理地认为,这一过程并没有显著提高思考的质量,而是鼓励模型在问题上徘徊更长的时间,直到对答案更有把握。
这仍然很酷,但它减弱了我在网上看到的对DeepSeek-R1的普遍看法,即它是一种接近AGI的、重要的、杰出的思考者。
当然,也许这种一般策略就足够了。我在Groq上测试了DeepSeek,Groq是世界上最快的生成AI推理的硬件。因为它能够以极快的速度处理令牌生成,即使是DeepSeek常见的长期思考过程也相对快速地完成。
我之前写过一篇关于Groq的文章。在准备那篇文章时,我与他们的工程副总裁Andrew Ling进行了长时间的对话。
在那次对话中,Andrew提到快速的令牌生成不仅比快速响应更重要,它还允许在及时的情况下进行更复杂和更高阶的推理。我认为DeepSeek的发布强烈支持了这一观点,并为专门为快速推理设计的定制硬件的光明未来提供了支持。
结论
因此,我们的探索结束了,本文介绍了 DeepSeek-R1,一个新的高性能开源模型。在探索这篇论文时,我们涵盖了很多内容。
首先,我们回顾了一些重要的思想,这些思想促成了 Deep Seek 的创建。我们回顾了训练 AI 模型的意义、变换器架构、GPT、思维链和 LoRA。
在了解了 DeepSeek-R1 创建的背景后,我们探讨了 DeepSeek-R1 论文中描述的各种模型,包括 DeepSeek-R1 模型本身,以及 R1 之前制作的几个初步模型和从 R1 中提炼出的模型。
然后,我们深入探讨了 DeepSeek-R1 的一个基本概念:强化学习。我们探讨了强化学习的高层次概念,然后深入研究了他们使用的一种名为“群体相对策略优化”(GRPO)的策略。
一旦我们理解了 GRPO 的一些重要机制,我们就从更高的层面再次审视了整个训练流程,探讨了一些细节,进行了本地模型的实验,并讨论了 DeepSeek-R1 可能对行业产生的影响。
非常感谢您的关注。在未来的文章中,我将使用 DeepSeek 的一些思想,比如 GRPO,来制作一个可以解决魔方的变换器。这应该会很有趣!