2023年的深度学习入门指南(20) - LLaMA 2模型解析

news/2024/5/13 2:07:12/文章来源:https://blog.csdn.net/lusing/article/details/131948963

2023年的深度学习入门指南(20) - LLaMA 2模型解析

上一节我们把LLaMA 2的生成过程以及封装的过程的代码简单介绍了下。还差LLaMA 2的模型部分没有介绍。这一节我们就来介绍下LLaMA 2的模型部分。
这一部分需要一些深度神经网络的基础知识,不懂的话不用着急,后面的文章我们都会介绍到。

均平方根标准化

RMSNorm是一种改进的LayerNorm技术,LayerNorm是Layer normalization,意思是层归一化。。层归一化用于帮助稳定训练并促进模型收敛,因为它具备处理输入和权重矩阵的重新居中和重新缩放的能力。

RMSNorm是2019年的论文《Root Mean Square Layer Normalization》中提出的。它假设LayerNorm中的重新居中性质并不是必需的,于是RMSNorm根据均方根(RMS)对某一层中的神经元输入进行规范化,赋予模型重新缩放的不变性属性和隐式学习率自适应能力。相比LayerNorm,RMSNorm在计算上更简单,因此更加高效。

理解了之后,我们来看下RMSNorm的代码实现。我把注释直接写在代码里。

class RMSNorm(torch.nn.Module):# 类的构造函数,它接受两个参数:dim和eps。dim是希望标准化的特征维度,eps是一个非常小的数,用于防止除以零的错误。def __init__(self, dim: int, eps: float = 1e-6):super().__init__()# 设置类的属性。eps是构造函数传入的参数。self.eps = eps# weight是一个可学习的参数,它是一个由1填充的张量,尺寸为dim。self.weight = nn.Parameter(torch.ones(dim))def _norm(self, x):# 首先,对输入x求平方并计算最后一个维度的平均值,然后加上一个非常小的数self.eps防止出现零,接着对结果开平方根并求倒数,最后将结果与原始输入x相乘。return x * torch.rsqrt(x.pow(2).mean(-1, keepdim=True) + self.eps)def forward(self, x):# 首先,它将输入x转化为浮点数并进行标准化,然后将标准化的结果转化回x的类型。最后,将结果与权重self.weight相乘,得到最终的输出。output = self._norm(x.float()).type_as(x)return output * self.weight

位置编码

我们复习下第3讲曾经介绍过的Transformer结构。

位置编码是Transformer中的一个重要组成部分,它的作用是为输入序列中的每个位置提供一个位置向量,以便Transformer能够区分不同位置的单词。

Transformer中的位置编码是通过将正弦和余弦函数的值作为位置向量的元素来实现的。这些函数的周期是不同的,因此它们的值在不同的位置是不同的。这样,Transformer就可以通过位置编码来区分不同位置的单词。

LLaMA并没有使用正弦函数。

# dim是特征的维度,end应该是预计算的时序位置的数量,theta是一个常数,用于调整频率的尺度
def precompute_freqs_cis(dim: int, end: int, theta: float = 10000.0):# 首先生成一个从0到dim的步长为2的整数序列,然后取前dim // 2个元素,将这些元素转换为浮点类型,然后除以dim# 得到的结果再次被用作theta的指数,最后取其倒数,得到一组频率freqs = 1.0 / (theta ** (torch.arange(0, dim, 2)[: (dim // 2)].float() / dim))# 生成一个从0到end的整数序列,这个序列在同一个设备上创建,这个设备是freqs的设备t = torch.arange(end, device=freqs.device)  # type: ignore# 计算t和freqs的外积,然后将结果转换为浮点类型freqs = torch.outer(t, freqs).float()  # type: ignore# 将freqs从直角坐标系转换为极坐标系# torch.polar(r, theta)的功能是根据极径r和极角theta生成复数,这里的r是freqs的形状的全1张量,theta则是freqs。freqs_cis = torch.polar(torch.ones_like(freqs), freqs)  # complex64return freqs_cis

将位置坐标计算出来之后,我们还需要将其变型成与输入的形状一致。
我们来看下是如何实现的:

def reshape_for_broadcast(freqs_cis: torch.Tensor, x: torch.Tensor):# 获取输入张量x的维度数ndim = x.ndimassert 0 <= 1 < ndimassert freqs_cis.shape == (x.shape[1], x.shape[-1])# 对于每个维度,如果它是第二个维度或最后一个维度,则保留原来的大小;否则,将其设置为 1shape = [d if i == 1 or i == ndim - 1 else 1 for i, d in enumerate(x.shape)]# 将freqs_cis调整为shape指定的形状,并返回结果return freqs_cis.view(*shape)

然后,将矩阵和位置编码相乘起来:

def apply_rotary_emb(xq: torch.Tensor,xk: torch.Tensor,freqs_cis: torch.Tensor,
) -> Tuple[torch.Tensor, torch.Tensor]:# 将 xq 和 xk 转换为复数张量,并将它们的形状调整为最后一个维度为 2 的形状xq_ = torch.view_as_complex(xq.float().reshape(*xq.shape[:-1], -1, 2))xk_ = torch.view_as_complex(xk.float().reshape(*xk.shape[:-1], -1, 2))# 调用刚刚讲过的 reshape_for_broadcast 函数来将 freqs_cis 调整为与 xq_ 兼容的形状freqs_cis = reshape_for_broadcast(freqs_cis, xq_)# 将xq_和xk_与freqs_cis进行逐元素的复数乘法,然后将得到的结果视为实数,最后将最后两个维度合并为一个维度xq_out = torch.view_as_real(xq_ * freqs_cis).flatten(3)xk_out = torch.view_as_real(xk_ * freqs_cis).flatten(3)# 使用 type_as 方法将其转换回与输入相同的数据类型return xq_out.type_as(xq), xk_out.type_as(xk)

LLaMA的注意力机制

解释下面代码:
class Attention(nn.Module):def __init__(self, args: ModelArgs):super().__init__()self.n_kv_heads = args.n_heads if args.n_kv_heads is None else args.n_kv_headsmodel_parallel_size = fs_init.get_model_parallel_world_size()self.n_local_heads = args.n_heads // model_parallel_sizeself.n_local_kv_heads = self.n_kv_heads // model_parallel_sizeself.n_rep = self.n_local_heads // self.n_local_kv_headsself.head_dim = args.dim // args.n_headsself.wq = ColumnParallelLinear(args.dim,args.n_heads * self.head_dim,bias=False,gather_output=False,init_method=lambda x: x,)self.wk = ColumnParallelLinear(args.dim,self.n_kv_heads * self.head_dim,bias=False,gather_output=False,init_method=lambda x: x,)self.wv = ColumnParallelLinear(args.dim,self.n_kv_heads * self.head_dim,bias=False,gather_output=False,init_method=lambda x: x,)self.wo = RowParallelLinear(args.n_heads * self.head_dim,args.dim,bias=False,input_is_parallel=True,init_method=lambda x: x,)self.cache_k = torch.zeros((args.max_batch_size,args.max_seq_len,self.n_local_kv_heads,self.head_dim,)).cuda()self.cache_v = torch.zeros((args.max_batch_size,args.max_seq_len,self.n_local_kv_heads,self.head_dim,)).cuda()

代码虽然多,但是都是实现head和q,k,v,很好理解:

  • 初始化时定义了n_kv_heads, n_local_heads等表示head数量的变量。
  • wq,wk,wv三个线性层分别用于生成query,key和value。采用ColumnParallelLinear实现分布并行。
  • wo线性层对多头attention的输出做融合,采用RowParallelLinear实现分布并行。
  • cache_k和cache_v用于缓存key和value,加速自注意力的计算。
  • 并行线性层的使用以及caching机制,可以加速自注意力在大batch大小场景下的训练和推理。
  • 整体设计实现了高效的分布式并行自注意力计算,可以扩展到大规模多GPU/机器环境,处理长序列任务。

然后,我们将各注意力的子模块集成起来:

    def forward(self,x: torch.Tensor,start_pos: int,freqs_cis: torch.Tensor,mask: Optional[torch.Tensor],):bsz, seqlen, _ = x.shapexq, xk, xv = self.wq(x), self.wk(x), self.wv(x)xq = xq.view(bsz, seqlen, self.n_local_heads, self.head_dim)xk = xk.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)xv = xv.view(bsz, seqlen, self.n_local_kv_heads, self.head_dim)xq, xk = apply_rotary_emb(xq, xk, freqs_cis=freqs_cis)self.cache_k = self.cache_k.to(xq)self.cache_v = self.cache_v.to(xq)self.cache_k[:bsz, start_pos : start_pos + seqlen] = xkself.cache_v[:bsz, start_pos : start_pos + seqlen] = xvkeys = self.cache_k[:bsz, : start_pos + seqlen]values = self.cache_v[:bsz, : start_pos + seqlen]# repeat k/v heads if n_kv_heads < n_headskeys = repeat_kv(keys, self.n_rep)  # (bs, seqlen, n_local_heads, head_dim)values = repeat_kv(values, self.n_rep)  # (bs, seqlen, n_local_heads, head_dim)xq = xq.transpose(1, 2)  # (bs, n_local_heads, seqlen, head_dim)keys = keys.transpose(1, 2)values = values.transpose(1, 2)scores = torch.matmul(xq, keys.transpose(2, 3)) / math.sqrt(self.head_dim)if mask is not None:scores = scores + mask  # (bs, n_local_heads, seqlen, cache_len + seqlen)scores = F.softmax(scores.float(), dim=-1).type_as(xq)output = torch.matmul(scores, values)  # (bs, n_local_heads, seqlen, head_dim)output = output.transpose(1, 2).contiguous().view(bsz, seqlen, -1)return self.wo(output)

主要流程如下:

  • 计算query、key、value的线性映射表示xq、xk、xv
  • 对xq和xk应用位置参数
  • 将xk、xv写入cache
  • 从cache读取key和value,重复其head维度以匹配query的head数
  • 计算query和key的点积获得相关度得分
  • 对scores加mask并softmax归一化
  • 将scores与value做权重和,得到多头自注意力输出
  • 将多头输出拼接并线性映射,即是Self-Attention的结果

其中用到了一个函数repeat_kv,它的作用是将key和value的head维度重复n_rep次,以匹配query的head数。

解释下面代码:
def repeat_kv(x: torch.Tensor, n_rep: int) -> torch.Tensor:"""torch.repeat_interleave(x, dim=2, repeats=n_rep)"""bs, slen, n_kv_heads, head_dim = x.shapeif n_rep == 1:return xreturn (x[:, :, :, None, :].expand(bs, slen, n_kv_heads, n_rep, head_dim).reshape(bs, slen, n_kv_heads * n_rep, head_dim))

repeat_kv函数使用 expand 方法将输入张量在第四个维度上扩展 n_rep 次,并使用 reshape 方法将其调整为适当的形状。

LLaMA的Transformer结构

核心的自注意力模块实现了之后,我们就可以像搭积木一样,将其组装成Transformer结构了。

首先我们看看全连接网络:

class FeedForward(nn.Module):def __init__(self,dim: int,hidden_dim: int,multiple_of: int,ffn_dim_multiplier: Optional[float],):super().__init__()hidden_dim = int(2 * hidden_dim / 3)# custom dim factor multiplierif ffn_dim_multiplier is not None:hidden_dim = int(ffn_dim_multiplier * hidden_dim)hidden_dim = multiple_of * ((hidden_dim + multiple_of - 1) // multiple_of)self.w1 = ColumnParallelLinear(dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x)self.w2 = RowParallelLinear(hidden_dim, dim, bias=False, input_is_parallel=True, init_method=lambda x: x)self.w3 = ColumnParallelLinear(dim, hidden_dim, bias=False, gather_output=False, init_method=lambda x: x)def forward(self, x):return self.w2(F.silu(self.w1(x)) * self.w3(x))

LLaMA的前馈神经网络主要是立足于并行化。

主要顺序为:

  • 初始化时构建了3个线性层w1,w2,w3。其中w1和w3使用ColumnParallelLinear实现分布式并行,w2使用RowParallelLinear。
  • forward时,先过w1做第一次线性投影,然后使用SiLU激活函数。
  • 跟一个w3对原输入做的线性投影加起来,实现残差连接。
  • 最后过w2线性层输出。

这样的结构形成了一个带残差连接的两层前馈网络。它结合并行计算和残差连接,使模型对长序列任务拟合效果更佳。

然后,我们将前馈全连接网络和之前讲的自注意力机制结合起来,构建Transformer块:

class TransformerBlock(nn.Module):def __init__(self, layer_id: int, args: ModelArgs):super().__init__()self.n_heads = args.n_headsself.dim = args.dimself.head_dim = args.dim // args.n_headsself.attention = Attention(args)self.feed_forward = FeedForward(dim=args.dim,hidden_dim=4 * args.dim,multiple_of=args.multiple_of,ffn_dim_multiplier=args.ffn_dim_multiplier,)self.layer_id = layer_idself.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,start_pos: int,freqs_cis: torch.Tensor,mask: Optional[torch.Tensor],):h = x + self.attention.forward(self.attention_norm(x), start_pos, freqs_cis, mask)out = h + self.feed_forward.forward(self.ffn_norm(h))return out

我们来分块讲解一下。

self.n_heads = args.n_heads
self.dim = args.dim
self.head_dim = args.dim // args.n_heads
self.attention = Attention(args)

首先,从参数对象中获取必要的参数,然后创建一个Attention对象。

self.feed_forward = FeedForward(dim=args.dim,hidden_dim=4 * args.dim,multiple_of=args.multiple_of,ffn_dim_multiplier=args.ffn_dim_multiplier,
)

然后,创建一个FeedForward对象,这个对象实现了前馈神经网络。

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)

接着,保存层的ID,并创建两个用于归一化的RMSNorm对象。

h = x + self.attention.forward(self.attention_norm(x), start_pos, freqs_cis, mask
)
out = h + self.feed_forward.forward(self.ffn_norm(h))
return out

最后,通过注意力机制和前馈神经网络,计算出输出数据。在注意力机制和前馈神经网络的前后,都使用了归一化操作,这有助于改善模型的训练稳定性。

最终,我们将上面所有的集成在一起,构建出LLaMA的Transformer结构:

class Transformer(nn.Module):def __init__(self, params: ModelArgs):super().__init__()self.params = paramsself.vocab_size = params.vocab_sizeself.n_layers = params.n_layersself.tok_embeddings = ParallelEmbedding(params.vocab_size, params.dim, init_method=lambda x: x)self.layers = torch.nn.ModuleList()for layer_id in range(params.n_layers):self.layers.append(TransformerBlock(layer_id, params))self.norm = RMSNorm(params.dim, eps=params.norm_eps)self.output = ColumnParallelLinear(params.dim, params.vocab_size, bias=False, init_method=lambda x: x)self.freqs_cis = precompute_freqs_cis(self.params.dim // self.params.n_heads, self.params.max_seq_len * 2)@torch.inference_mode()def forward(self, tokens: torch.Tensor, start_pos: int):_bsz, seqlen = tokens.shapeh = self.tok_embeddings(tokens)self.freqs_cis = self.freqs_cis.to(h.device)freqs_cis = self.freqs_cis[start_pos : start_pos + seqlen]mask = Noneif seqlen > 1:mask = torch.full((1, 1, seqlen, seqlen), float("-inf"), device=tokens.device)mask = torch.triu(mask, diagonal=start_pos + 1).type_as(h)for layer in self.layers:h = layer(h, start_pos, freqs_cis, mask)h = self.norm(h)output = self.output(h).float()return output

到了大结局阶段,可解释的就不用了。
最终的模块唯一增加的组件就是词嵌入部分:

        self.tok_embeddings = ParallelEmbedding(params.vocab_size, params.dim, init_method=lambda x: x)

然后把Transformer块打包在一起:

        self.layers = torch.nn.ModuleList()for layer_id in range(params.n_layers):self.layers.append(TransformerBlock(layer_id, params))

加上归一化:

        self.norm = RMSNorm(params.dim, eps=params.norm_eps)self.output = ColumnParallelLinear(params.dim, params.vocab_size, bias=False, init_method=lambda x: x)

最后,所有层都走一遍,再来一遍归一化,大功告成:

for layer in self.layers:h = layer(h, start_pos, freqs_cis, mask)
h = self.norm(h)
output = self.output(h).float()
return output

小结

至此,LLaMA2的主要代码我们就走马观花地学习了一遍。哪怕有些细节还不能理解,起码我们掌握了一个真正的大模型代码的地图。

大家有不理解的地方也不要紧。一方面,后面我们会针对框架的通用技术再进行一些介绍。另一方面,我们还要解析多个其它的开源大模型的源代码。量变引起质变,大家多思考,多试验,就一定能理解大模型的代码。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.luyixian.cn/news_show_335855.aspx

如若内容造成侵权/违法违规/事实不符,请联系dt猫网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

向npm注册中心发布包(下)

目录 1、在package.json文件中指定dependencies和devDependencies 1.1 将依赖项添加到 package.json 文件 1.2 从命令行中 将依赖项添加到 package.json 文件 1.3 手动编辑 package.json 文件 2、关于语义版本控制 2.1 在已发布的包中增加语义版本 2.2 使用语义版本控制…

Vue实现柱状图横向自动滚动

Vue实现柱状图横向自动滚动 1. 前言2. 代码3、实现效果图 1. 前言 原理&#xff1a;通过定时器修改Echarts的配置&#xff08;options&#xff09;达到我们想要的效果。 此外&#xff0c;我们还需要了解Echarts中dataZoom这个组件&#xff0c;这个组件用于&#xff1a;用于区域…

探究Spring Bean的六种作用域:了解适用场景和使用方式

这里写目录标题 单例&#xff08;Singleton&#xff09;作用域&#xff1a;原型&#xff08;Prototype&#xff09;作用域&#xff1a;请求&#xff08;Request&#xff09;作用域&#xff1a;会话&#xff08;Session&#xff09;作用域&#xff1a;全局&#xff08;applicati…

MySQL绿色安装和配置

1、 从地址http://dev.mysql.com/downloads/mysql/中选择windows的版本下载。 2、 mysql各个版本的简介 &#xff08;1&#xff09; MySQL Community Server 社区版本&#xff0c;开源免费&#xff0c;但不提供官方技术支持。 &#xff08;2&#xff09; MySQL Enterprise Ed…

文件上传--题目

之前有在技能树中学过文件上传&#xff0c;正好借这次进行一个整合&#xff1a; 技能树中所包含的题目类型有 无限制绕过 1.上传一句话木马 2.链接中国蚁剑 前端验证 1.会发现这个网站不让提交php&#xff0c;改后缀为jpg格式&#xff0c;再用burp抓包 2.在用中国蚁剑连接 .…

[start] m40 test

software & update 470 drive version # cd /etc/apt # mv sources.list sources.list.bak # sudo vi /etc/apt/sources.list # 默认注释了源码镜像以提高 apt update 速度&#xff0c;如有需要可自行取消注释 deb https://mirrors.tuna.tsinghua.edu.cn/ubuntu/ ja…

Linux搭建Promtail + Loki + Grafana 轻量日志监控系统

一、简介 日志监控告警系统&#xff0c;较为主流的是ELK&#xff08;Elasticsearch 、 Logstash和Kibana核心套件构成&#xff09;&#xff0c;虽然优点是功能丰富&#xff0c;允许复杂的操作。但是&#xff0c;这些方案往往规模复杂&#xff0c;资源占用高&#xff0c;操作苦…

【代码随想录day20】验证二叉搜索树

题目 给你一个二叉树的根节点 root &#xff0c;判断其是否是一个有效的二叉搜索树。 有效 二叉搜索树定义如下&#xff1a; 节点的左子树只包含 小于 当前节点的数。 节点的右子树只包含 大于 当前节点的数。 所有左子树和右子树自身必须也是二叉搜索树。 思路 最开始想简单…

htmlCSS-----定位

目录 前言 定位 分类和取值 定位的取值 1.相对定位 2.绝对位置 元素居中操作 3.固定定位 前言 今天我们来学习html&CSS中的元素的定位&#xff0c;通过元素的定位我们可以去更好的将盒子放到我们想要的位置&#xff0c;下面就一起来看看吧&#xff01; 定位 定位posi…

安全技术-大数据平台安全防护技术

一、大数据基本概念及背景 1.1大数据发展的背景-数据爆炸 伴随着互联⽹、物联⽹、电⼦商务、社交媒体、现代物流、⽹络⾦融等⾏业的发展&#xff0c;全球数据总量正呈⼏何级数增长&#xff0c;过去⼏年时间产⽣的数据总量超过了⼈类历史上的数据总和&#xff0c;预计2020年全…

C语言每日一题:5.至少是其他数字的两倍+两个数组的交集。

第一题&#xff1a;至少是两倍其他数字的最大数 第一题&#xff1a; 思路一&#xff1a; 1.需要我们返回最大数值的下标&#xff0c;所以先循环遍历我们的这个数组记录一下最大的数值和下标位置。 2.使用qsort排序&#xff08;总是存在唯一的最大整数&#xff09; 3所以排序之…

tinkerCAD案例:11.制作齿轮

tinkerCAD案例&#xff1a;11.制作齿轮 制作齿轮 Add a cylinder to be the main part of the gear. 添加一个圆柱体作为齿轮的主要部分。 说明 Click and drag a cylinder onto the Workplane. 单击圆柱体并将其拖动到工作平面上。 Change the cylinder dimensions to 35mm …

汽车交流充电桩控制主板的电路设计

汽车充电桩控制主板的电路设计 你是否曾经遇到过汽车没油的问题?但是&#xff0c;随着电动汽车的普及&#xff0c;充电问题也变得越来越重要。而汽车充电桩控制板电路设计则是解决这一问题的关键。 汽车充电桩控制板电路设计包括硬件电路设计、软件电路设计和安全性设计。硬件…

四章:Constrained-CNN losses for weakly supervised segmentation——弱监督分割的约束CNN损失函数

0.摘要 基于部分标记图像或图像标签的弱监督学习目前在CNN分割中引起了极大关注&#xff0c;因为它可以减轻对完整和繁琐的像素/体素注释的需求。通过对网络输出施加高阶&#xff08;全局&#xff09;不等式约束&#xff08;例如&#xff0c;约束目标区域的大小&#xff09;&am…

实战项目——基于多设计模式下的同步异步日志系统

系列文章目录 1.项目介绍 2.相关技术补充 3.日志系统框架 4.代码设计 5.功能测试 6.性能测试 文章目录 目录 系列文章目录 1.项目介绍 2.相关技术补充 3.日志系统框架 4.代码设计 5.功能测试 6.性能测试 文章目录 前言 一、项目介绍 二、开发环境 三、核心技…

利用Stable diffusion Ai 制作艺术二维码超详细参数和教程

大家有没有发现最近这段时间网上出现了各种各样的AI艺术二维码&#xff0c;这种二维码的出现&#xff0c;简直是对二维码的“颠覆式创新”&#xff0c;直接把传统的二维码提升了一个维度&#xff01;作为设计师的我们怎么可以不会呢&#xff1f; 今天就教大家怎么制作这种超有艺…

【数据动态填充到element表格;将带有标签的数据展示为文本格式】

一&#xff1a;数据动态填充到element表格&#xff1b; 二&#xff1a;将带有标签的数据展示为文本格式&#xff1b; 1、 <el-row><el-col :span"24"><el-tabs type"border-card"><el-tab-pane label"返回值"><el-…

嵌入式:QT Day2

一、继续完善登录框&#xff0c;当登陆成功时&#xff0c;关闭登陆页面&#xff0c;跳转到新的界面中 源码&#xff1a; widget.h #ifndef WIDGET_H #define WIDGET_H#include <QWidget> #include <QDebug> //用于打印输出 #include <QIcon> …

git拉取项目报错:fatal: remote error: Service not enabled

一般是git地址错误&#xff0c;如果是原本就有的项目&#xff0c;看看是不是代码库移动到其他地方了&#xff0c;这个库已经被删除了

Centos yum install出现Error: Unable to find a match: epel-release的解决方案

大家好,我是爱编程的喵喵。双985硕士毕业,现担任全栈工程师一职,热衷于将数据思维应用到工作与生活中。从事机器学习以及相关的前后端开发工作。曾在阿里云、科大讯飞、CCF等比赛获得多次Top名次。现为CSDN博客专家、人工智能领域优质创作者。喜欢通过博客创作的方式对所学的…