Zhangzhe's Blog

The projection of my life.

0%

0. 背景

  • 本文主要介绍 hugging face 托管的 LLM 如何下载,并用 transformers repo 推理

1. 相关工具介绍

  1. hugging face 是一个托管 LLM 的网站,类似 github / dockerhub,可以上传下载大模型,也可以在线推理等
  2. transformers 是一个 hugging face 维护的开源大模型代码库,包含约 300 个开源大模型的网络结构,可以配合 hugging face 下载的大模型,实现一键推理

2. 模型文件下载

2.1 下载工具

  1. hugging face 有专用工具 huggingface-cli,作用类似于低配版 git,可以实现远程 hugging face repo 的登录、登出、上传、下载等
  2. 对于 Qwen2-0.5B-Instruct 模型,huggingface-cli 可直接免登录下载,而例如 Llama3.2-1B 等模型需要向 meta 提交申请并获得同意后下载

2.2 下载文件内容

  1. hugging face 模型文件格式较为固定,一般为:
    1. config.json
      1. 这个文件包含了模型的配置信息,比如模型的架构参数、词表大小、激活函数类型等
      2. 它通常用于在加载模型时,确保模型的配置与原始模型一致
    2. generation_config.json
      1. 这个文件包含了生成文本时的配置信息,比如最大长度、停止序列、解码相关配置(温度、top-ktop-p、惩罚系数)等,以及依赖的 transformers 版本
      2. 这些参数影响模型生成文本的行为
    3. LICENSE
      1. 这个文件包含了模型的许可证信息,说明了你可以如何使用这个模型,以及使用时需要遵守的法律条款
    4. merges.txt
      1. 这个文件通常用于字节对编码(Byte Pair Encoding, BPE)分词器,它包含了词汇的合并规则
      2. BPE 是一种用于创建词汇表和编码文本的算法
    5. model.safetensors
      1. 这是一个保存模型权重的文件,使用了 SafeTensors 格式
      2. 这是一种高效的文件格式,用于存储和加载深度学习模型的权重
    6. README.md
      1. 这个文件包含了模型的说明文档,通常包括模型的简介、如何使用模型、训练细节、性能指标等信息
    7. tokenizer_config.json
      1. 这个文件包含了分词器的配置信息,比如分词器的类型、是否使用 BPE
    8. tokenizer.json
      1. 这个文件包含了分词器的预训练信息,比如词汇表、特殊标记等
      2. 它用于将文本转换为模型可以理解的数值输入
    9. vocab.json
      1. 这个文件包含了模型的词汇表,即模型在训练时使用的所有单词或标记的列表
      2. 分词器使用这个词汇表将文本分割成模型可以理解的输入
  2. Qwen2-0.5B-Instruct 中的 "Instruct" 是指此模型是经过指令遵循微调后的模型

2.3 模型参数解析

  1. 下载的文件中 model.safetensors 就是模型参数
  2. safetensors 是由 Hugging Face 开发的一种新型文件格式,专门用于安全地存储和加载机器学习模型的权重,这种格式特别关注模型的安全性、隐私保护和快速加载
    1. 安全性:safetensors 格式的设计目标之一是提高模型存储的安全性,它不允许执行代码,从而减少了模型文件被恶意篡改的风险
    2. 快速加载:与传统的 PyTorch 序列化格式(如 .pth.bin)相比,safetensors 可以更快地加载模型权重,尤其是在 CPUGPU
    3. 跨语言和跨框架兼容性:safetensors 支持在不同的编程语言和机器学习框架之间共享模型权重,例如可以在 Python 中序列化并在 C++ / Java / JavaScript 中加载
    4. 懒加载:支持懒加载,即可以选择只读取文件中的一部分张量,这对于分布式设置中的多节点或 GPU 非常有用
  3. 如何解析一个 model.safetensors 文件?
1
2
3
4
5
6
from safetensors import safe_open
tensors = {}
# framework="pt" means pytorch
with safe_open("model.safetensors", framework="pt", device="cpu") as f:
for key in f.keys():
tensors[key] = f.get_tensor(key)

3. 构建模型并加载预训练参数

1
2
3
4
from transformers import AutoModelForCausalLM
model = AutoModelForCausalLM.from_pretrained(
model_path, torch_dtype="auto", device_map="auto"
)
  • hugging facetransformers 库提供了一个 AutoModelForCausalLM 类,可以根据模型路径,读取配置文件并自动加载模型

4. 输入构建

输入构建分成三个部分:

  1. 初始化 tokenizer
  2. prompt 指令化
  3. text to token idtokenize

4.1 初始化 tokenizer

1
2
from transformers import AutoTokenizer
tokenizer = AutoTokenizer.from_pretrained(model_path)
  • transformers 会自动读取 tokenizer 相关配置文件,创建对应的 tokenizer 对象

4.2 prompt 指令化

1
2
3
4
5
6
7
8
9
10
11
12
13
prompt = "艾萨克牛顿是谁?"
messages = [
{
"role": "system",
"content": "You are Qwen, created by Alibaba Cloud. You are a helpful assistant.",
},
{"role": "user", "content": prompt},
]
text = tokenizer.apply_chat_template(
messages,
tokenize=False,
add_generation_prompt=True,
)
  • 上面的 messageQwen2 指令微调后模型的输入格式,包含了 rolecontent 两个字段
  • 具体来说需要两个步骤:
    1. prompt to message:将 prompt 转换为 message 格式
    2. message to chat:在 message 中插入特殊字符,形成 chat 格式,特殊字符主要包括:
      1. <|im_start|>:表示对话开始
      2. <|im_end|>:表示对话结束
      3. \n:间隔 rolecontent
  • 最终生成的 text 实际内容为:
1
<|im_start|>system\nYou are Qwen, created by Alibaba Cloud. You are a helpful assistant.<|im_end|>\n<|im_start|>user\n艾萨克牛顿是谁?<|im_end|>\n<|im_start|>assistant
  • 一些额外知识:
    1. prompt 输入模板信息在 tokenizer_config.json 中被定义过,keychat_template
      1
      {% for message in messages %}{% if loop.first and messages[0]['role'] != 'system' %}{{ '<|im_start|>system\nYou are a helpful assistant.<|im_end|>\n' }}{% endif %}{{'<|im_start|>' + message['role'] + '\n' + message['content'] + '<|im_end|>' + '\n'}}{% endfor %}{% if add_generation_prompt %}{{ '<|im_start|>assistant\n' }}{% endif %}
    2. 模板用到的代码是 Jinja2 写的,Jinja2 是一种模板引擎,tokenizer 会根据这个模板生成 chat 格式的 text
    3. 代码中的 messagesadd_generation_prompt 是模板的输入参数

4.3 tokenize(text to token id)

1
model_inputs = tokenizer([text], return_tensors="pt").to(model.device)
  • 这个过程就是 tokenize,将 text 转换为 token id
    • 首先根据 tokenizer.json 中声明的 token encode 方法(例如 BPE),将 text 分词
    • 然后根据 vocab.json 中的词汇表,将分词后的词转换为 token id(查表)
    • 最后将 token id 转换为 tensor 格式(tensor of uint64 dtype

5. 模型前向传播

1
2
3
4
generated_ids = model.generate(
**model_inputs,
max_new_tokens=512,
)
  • generate 方法是 transformers 提供的一个高级封装方法,可以实现 LLM 的推理
    • max_new_tokens 参数表示最大生成的 token 数量
    • generate 方法会自动迭代调用模型的前向传播方法和解码方法,实现文本生成
  • 本章节的重点关注 generate 的模型推理过程,可以分成:
    1. token id to token embedding
    2. position emebedding 构造
    3. attention mask 构造
    4. decoder layer forward

5.1 token id to token embedding

  • nn.Embedding 本质就是查表,将 token id 转换为 token embedding

5.2 position emebedding 构造

  • Qwen2 模型使用的 position embedding 方法是 RoPE (rotary position embedding)

5.2.1 RoPE 介绍

RoPE 的核心思想是将 query embeddingkey embedding 旋转一定角度,让模型感知到序列中的位置信息。

  • RoPE 的输入是:
    1. position id(例如:[0, 1, 2, ..., seq_len - 1]
    2. query embedding (shape = [seq_len, head_dim])
    3. key embedding (shape = [seq_len, head_dim])
  • RoPE 的输出是:
    1. 经过 RoPE 处理后的 query embedding
    2. 经过 RoPE 处理后的 key embedding
  • RoPE 的计算过程是:
    1. 构造 cosRseq_len×head_dim\cos\in\mathbb{R}^{seq\_len\times head\_dim}sinRseq_len×head_dim\sin\in\mathbb{R}^{seq\_len\times head\_dim} 两个矩阵
    2. 用两个矩阵去旋转 query embeddingkey embedding
  • RoPE 的实现代码如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
# 构造 RoPE 旋转矩阵
inv_freq = 1.0 / (1000000 ** (torch.arange(0, dim, 2, dtype=torch.int64).float().to(device) / dim)) # 1. 计算频率的倒数,shape = [dim // 2]
freq = inv_freq_expanded.float() @ position_ids_expanded.float() # 2. 计算频率,shape = [seq_len, dim // 2]
emb = torch.cat((freqs, freqs), dim=-1) # 3. 构造 RoPE 矩阵,shape = [seq_len, dim]
cos = emb.cos()[None, None, ...] # 4. 计算 RoPE 的 cos,扩展到多 batch 和多头,shape = [1, 1, seq_len, dim]
sin = emb.sin()[None, None, ...] # 5. 计算 RoPE 的 sin,扩展到多 batch 和多头,shape = [1, 1, seq_len, dim]
# 旋转 query embedding 和 key embedding
def rotate_half(x):
x1 = x[..., : x.shape[-1] // 2]
x2 = x[..., x.shape[-1] // 2 :]
return torch.cat((-x2, x1), dim=-1)
q = (q * cos) + (rotate_half(q) * sin) # 6. 旋转 query embedding,shape = [batch_size, num_heads, seq_len, head_dim]
k = (k * cos) + (rotate_half(k) * sin) # 7. 旋转 key embedding,shape = [batch_size, num_heads, seq_len, head_dim]
  • 以上 RoPE 的公式解释:
    • xm,i=xm,icos(mθi)xm,i+d/2sin(mθi)x'_{m,i}=x_{m,i}\cdot \cos (m\theta_i)-x_{m,i+d/2}\cdot \sin (m\theta_i)
    • xm,i+d/2=xm,icos(mθi)+xm,i+d/2sin(mθi)x'_{m,i+d/2}=x_{m,i}\cdot \cos (m\theta_i)+x_{m,i+d/2}\cdot \sin (m\theta_i)
    • 其中:
      • xm,ix_{m,i}query embeddingkey embedding 的第 mm 个位置的第 ii 个维度的值
      • θi=10000002id\theta_i=1000000^{-\frac{2i}{d}}
      • mθi=m10000002idm\theta_i=m\cdot 1000000^{-\frac{2i}{d}}mm 是位置 id
      • ddhead_dim
  • Qwen 模型中的 RoPE 实现代码是 RoPE 的一个简化版本,主要是为了提高计算效率,官方的 RoPE 计算公式如下:
    • xm,2i=xm,2icos(mθi)xm,2i+1sin(mθi)x'_{m,2i}=x_{m,2i}\cdot \cos (m\theta_i)-x_{m,2i+1}\cdot \sin (m\theta_i)
    • xm,2i+1=xm,2icos(mθi)+xm,2i+1sin(mθi)x'_{m,2i+1}=x_{m,2i}\cdot \cos (m\theta_i)+x_{m,2i+1}\cdot \sin (m\theta_i)
    • RdΘ,mx=[x1x2x3x4xd1xd][cos(mθ1)cos(mθ1)cos(mθ2)cos(mθ2)cos(mθd/2)cos(mθd/2)]+[x2x1x4x3xdxd1][sin(mθ1)sin(mθ1)sin(mθ2)sin(mθ2)sin(mθd/2)sin(mθd/2)]\mathbf{R}_{d\Theta,m}\mathbf{x} = \begin{bmatrix} \mathbf{x}_1 \\ \mathbf{x}_2 \\ \mathbf{x}_3 \\ \mathbf{x}_4 \\ \vdots \\ \mathbf{x}_{d-1} \\ \mathbf{x}_d \end{bmatrix} \otimes \begin{bmatrix} \cos(m\theta_1) \\ \cos(m\theta_1) \\ \cos(m\theta_2) \\ \cos(m\theta_2) \\ \cdots \\ \cos(m\theta_{d/2}) \\ \cos(m\theta_{d/2}) \end{bmatrix} + \begin{bmatrix} -\mathbf{x}_2 \\ \mathbf{x}_1 \\ -\mathbf{x}_4 \\ \mathbf{x}_3 \\ \vdots \\ -\mathbf{x}_d \\ \mathbf{x}_{d-1} \end{bmatrix} \otimes \begin{bmatrix} \sin(m\theta_1) \\ \sin(m\theta_1) \\ \sin(m\theta_2) \\ \sin(m\theta_2) \\ \cdots \\ \sin(m\theta_{d/2}) \\ \sin(m\theta_{d/2}) \end{bmatrix}
    • 区别在于:
      • 官方的 RoPE 是同一个位置的相邻维度(2i 和 2i+1)之间旋转
      • QwenRoPE 是同一个位置的跳跃维度(i 和 i+d/2)之间旋转

5.3 attention mask 构造

  • 实现中,attention mask 实际上并不需要构造,因为 torch.nn.functional.scaled_dot_product_attentionis_causal 参数可以自动实现 attention mask
  • 具体来说:
    1. torch.nn.functional.scaled_dot_product_attentionis_causal == True 时,会自动构造一个 attention biasshape = [query_len, key_len],其中 bias[i, j] = -inf if i > j else 0
    2. query @ key.transpose(-2, -1) 得到 attention score 后,会自动加上这个 attention bias
    3. 然后再经过 softmax,这样就实现了 causal attention
  • 而且 causal attentionGPT-like LLM 中,只在 prefill 阶段使用,generate 阶段不使用(或者说隐式使用)

5.4 decoder layer forward

  • LLM 的计算量几乎都体现在 decoder layer forward
  • layer norm 位置角度来看,Qwen2 模型属于 pre-LM,即:
    1. x=x+MHA(LayerNorm(x))x=x+MHA(LayerNorm(x))
    2. x=x+FFN(LayerNorm(x))x=x+FFN(LayerNorm(x))
    3. x=LayerNorm(x)x = LayerNorm(x)
  • 模型包含 24decoder layer,每层包含 2 个子层:
    1. multi-head self-attentionMHA
    2. feed forward networkFFN
  • 为了减小计算量和参数量,Qwen2 模型使用了 GQA (grouped query attention) 方法:
    1. 对于 query 来讲,每层 MHA 包含 14 个头,每个头的 head_dim = 64
    2. 对于 keyvalue 来讲,每层 MHA 包含 2 个头,每个头的 head_dim = 64
    3. 计算时,需要将 keyvaluehead 通过 repeat 扩展到 14 个头
  • 同时,Qwen2 使用了 KV cache 算法,即:
    1. prefill 阶段,keyvalue 会被缓存下来
    2. generate 阶段,之前的 keyvalue 会被直接使用不再计算,只计算最后一个 tokenquery / key / value
  • prefill 阶段:
    • 输入为 hidden stateshape = [1, 36, 896],其中 36seq_len896hidden_dim
    • 输入逐层经过 decoder layer,最后输出 hidden state,和输入 shape 一样(shape = [1, 36, 896]
    • 在这一阶段,每一层的每个头的每个位置(token position)的 keyvalue 都会被缓存下来,shape = [2, 1, 24, 2, 36, 64],其中:
      • 2key / value
      • 1batch_size
      • 24layers
      • 36seq_len
      • 2key_value_head_num
      • 64head_dim
    • 每个 decoder layer 层的 MHA 都需要 causal attention,即 is_causal = True
    • 输出的 hidden state 最后一个 token positionfeature (shape = [1, 896]) 会被输入到 fc 层,输出 shape = [1, 151936]151936vocab_size
    • 然后通过 decode method 得到最终的 token id
  • generate 阶段:
    • 输入为 预填充阶段最终输出的 token id 对应的 token embeddingshape = [1, 1, 896]
    • 输入逐层经过 decoder layer,最后输出 hidden state,和输入 shape 一样(shape = [1, 1, 896]
    • 在这一阶段,每一层的每个头的之前的位置(token position)的 keyvalue 都会被直接使用,不再计算;只计算最后一个 tokenquery / key / value,并将计算得到的 key / value 缓存下来,缓存的 key / value 会拼接到之前的 key / value 上,形成新的 key / valueshape = [2, 1, 24, 14, 36 + 1, 64]
    • 每个 decoder layer 层的 MHA 的输入 shape 如下:
      • query: shape = [1, 14, 1, 64]
      • key / value: shape = [1, 14, 37, 64]
      • is_causal = False,因为 query 长度为 1,不需要 causal attention
    • 最后一层 decoder layer 的输出经过 fcdecode method 得到最终的 token id
    • 重复直到生成的 token id 数量达到 max_new_tokens 或生成的 token ideos 为止

6. decode method

  • decode method 实际上属于模型 forward 过程中的一部分,但是为了方便理解,这里单独拎出来讲
  • decode method 的作用是将 vocab logits 映射到 token id
  • 最简答的 decode methodargmax 方法,即取 vocab logits 中最大的值对应的 token id,但是这种方法会导致生成的文本过于单一
  • 实际上 Qwen2 使用了五个串联的 decode method,分别是:
    • RepetitionPenaltyLogitsProcessor
    • TemperatureLogitsWarper
    • TopKLogitsWarper
    • TopPLogitsWarper
    • SoftmaxSampler

6.1 RepetitionPenaltyLogitsProcessor

RepetitionPenaltyLogitsProcessor 的作用是惩罚重复的 token id,即:

  1. 在预测的 vocab logits 中,找到之前所有 token id 对应的 logits
  2. 如果 logits > 0,则将 logits 除以一个 penaltypenalty 的值取 1.1
  3. 如果 logits < 0,则将 logits 乘以一个 penaltypenalty 的值取 1.1
  4. 目的是让模型更倾向于生成不重复的 token id

6.2 TemperatureLogitsWarper

TemperatureLogitsWarper 的作用是调整 vocab logits 的温度,即:

  1. vocab logits 除以一个 temperaturetemperature 的值取 0.7
  2. 目的是提高模型的生成多样性

6.3 TopKLogitsWarper

TopKLogitsWarper 的作用是截断 vocab logits,即:

  1. 保留 vocab logits 中最大的 k 个值,其他值置为 -infk 的值取 20
  2. 目的是降低模型的生成多样性,提高生成的准确性

6.4 TopPLogitsWarper

TopPLogitsWarper 的作用是截断 vocab probs,即:

  1. vocab logit 通过 softmax 变成 vocab probs
  2. vocab probs 从大到小排序,累加到 p 大于 top-p 为止,保留这些 vocab logits,其他值置为 -inf
  3. top-p 的值取 0.8,最终至少需要保留一个 token id
  4. 目的是降低模型的生成多样性,提高生成的准确性

6.5 SoftmaxSampler

  1. vocab logits (此时只有少量元素不是 -inf) 通过 softmax 变成 vocab probs
  2. 根据 vocab probs 采样一个 token id,作为最终的输出

7. 总结

  • 本文主要介绍了 hugging facetransformers 的使用方法,以及 Qwen2-0.5B-Instruct 模型的推理过程
  • GPT-like 模型的推理过程主要包括 tokenizeRoPEattention maskdecoder layer forwarddecode method 等步骤
  • Qwen2 模型使用了 RoPEGQAKV cache 等技术,提高了模型的计算效率和参数量
  • decode method 使用了 RepetitionPenaltyLogitsProcessorTemperatureLogitsWarperTopKLogitsWarperTopPLogitsWarperSoftmaxSampler 等方法,提高了模型的生成多样性和准确性

0. 背景知识

  1. NAS 是什么?
    • NAS 的全称是 Network Attached Storage,网络附加存储,说白了就是一个低性能高容量的私有云服务器
  2. NAS 能干什么用?
    • NAS 可以作为家庭影院数据中心,NAS + 局域网文件传输协议 + 智能电视 + 家庭影院软件可以实现非常好的家庭观影体验
    • NAS 可以作为私有云服务器,承担例如图床/视频床等功能,也可以建网站
  3. 怎么做一个 NAS
    • 通常情况下,直接买一个成品 NAS 是一个省心的选择,外观看通常是一个可以插硬盘的盒子,本质是一个 PC
    • 我更喜欢自己手搓

1. 手搓 NAS 方案

  1. NAS 手搓需要搞定 NAS 本体和网络两大块
    1. NAS 本体负责下载数据,保存数据和局域网内数据共享
    2. 网络部分负责让 NAS 本体成为一个公网可访问的设备
  2. NAS 本体用树莓派 5 实现是一个不错的选择
  3. 网络部分用光猫桥接 + DDNS + 端口转发方案

2. NAS 本体

硬件

  1. NAS 本体是一个 4GB 版本树莓派 5
  2. 一个 32GBSD 卡作为系统盘
  3. 一块 500GBUSB 3.0 的移动机械硬盘(后续根据需求扩容)

软件

  1. 操作系统:Ubuntu 24.04 Desktop arm64下载地址
    1. Ubuntu 的好处:社区活跃,遇到问题容易解决
    2. 坏处:预安装软件很多,比较大
  2. 其他软件:根据需求安装,例如:
    1. Samba 局域网共享软件
    2. RDP 远程桌面软件
    3. Transmission 磁力链接下载器

3. 网络

NAS 本体比起来,网络才是手搓 NAS 最难的部分,主要包含如下几个部分

公网 IP

  1. 选择的宽带运营商是电信,电信的入户光纤是动态公网 IPv4 地址(这一点还是比较良心的)
  2. 光猫默认开启了路由模式,因此路由器的输入已经变成了局域网,为了简化网络拓扑,需要给电信客服打电话要求将光猫改成桥接模式,即光猫只承担光电转换功能,输出端口 IP 和输入端口 IP 一致
  3. 光猫开启桥接模式之后,路由器输入端口变成了公网 IP,通过 PPPoE 拨号上网,同时实测发现,光猫桥接模式下,下载速度更快

DDNS

  1. 虽然路由器的输入已经变成公网 IP,但是动态的,几乎每天都在发生变化
  2. 因此需要用到 DDNS 服务,让动态 IP 绑定到静态的域名上,域名之前已购买,只需要在顶级域名之前加上一个 A 记录的子域名
  3. 由于家里用的路由器是小米 be6500 pro 型号,这个路由器本身并不支持腾讯云(dnspod)的 DDNS 服务,所以有两条路可以选:
    1. 刷机到 Openwrt 路由器固件,这个开源固件功能十分强大,但坏处是路由器无法再绑定到米家 APP 实现远程控制的一些官方功能
    2. 另外一种方法是通过某种方法开启路由器的 ssh 权限,登录到路由器内部,在内部开启 dnspod DDNS
  4. 这里选择了第二种方法,ssh 开启教程在 这里
  5. 开启 ssh 后,就可以登录到路由器 terminal,然而路由器使用的是小米自己魔改的 XiaoQiang Linux 操作系统,包管理工具以及软件源什么的完全搞不懂,也很难查到相关资料,所以常用的 DDNS 服务软件例如 ddns-go 什么的也无法正常安装和配置
  6. 在仔细了解了 DDNS 服务的基本原理之后,决定用 SHELL 手写一个 DDNS 服务
    1. DDNS 的原理(仅在 dnspod 服务商上测试过):客户端(路由器)定期检查输入端口 IP,当发现和 dnspod 服务商记录的这个域名绑定的 IP 不一致时,就给 dnspod 发一条修改绑定关系的请求,dnspod 更改后,新的 “域名——IP” 绑定关系就建立了
    2. 这里涉及到几个关键:
      1. API Token:上面这个修改过程显然不是任何人都可以改的,你只能修改自己名下的域名绑定的 IP,所以需要一个密钥来和 dnspod 服务器交互,这个密钥在 https://console.dnspod.cn/account/token/token 创建
      2. 查找域名和记录对应的 ID:由于 dnspodDDNS API 要求域名和对应的记录是以 ID 的方式描述的,所以需要查到域名 ID 和记录 ID
        1. 域名 ID
          1. curl -s "https://dnsapi.cn/Domain.List" -d "login_token=<your_token>&format=json" 得到输出查询结果
          2. 然后在查询结果中找到名下多个域名中想要查询域名的 ID
        2. 记录 ID
          1. curl -s "https://dnsapi.cn/Record.List" -d "login_token=<your_token>&format=json&domain_id=<your_domain_id>&sub_domain=<your_sub_domain>" 得到查询结果
          2. 在查询到的此域名下多条记录中,找到关注的记录 ID
      3. 查询当前 IPcurl -s http://ipinfo.io/ip
      4. 更新 DNS 记录:curl -s -X POST "https://dnsapi.cn/Record.Modify" -d "login_token=${API_TOKEN}" -d "format=json" -d "domain_id=${DOMAIN_ID}" -d "record_id=${RECORD_ID}" -d "sub_domain=${SUB_DOMAIN}" -d "record_line=${RECORD_LINE}" -d "record_type=${RECORD_TYPE}" -d "value=${CURRENT_IP}"
    3. 然后将此 DDNS_update.sh 文件注册到 crontab 中,每 5 分钟更新一次
  7. 确实可以正常更新 DNS,不过忙完之后才发现可以在树莓派上实现,不用自己写 SHELL

端口转发

  • 外网只能访问到路由器,如果想要通过 ssh 实现外网直连树莓派,那么需要在路由器上配置端口转发
  • 由于目前只有 SSHVNC 两个远程访问需求,所以只开了 22TCP 端口转发
  • VNC 可以通过 ssh + 端口转发实现,不用开 5900 端口,开的越少内网设备越安全
  • VNC 具体使用方法
    1. 在树莓派上开启 VNC 服务,并打开 5900 监听端口
    2. 由于路由器只配置了 22 端口转发,所以这个 5900 端口在公网是无法访问的
    3. 这时候就需要用到 ssh 的端口转发功能,在本地电脑上执行如下命令:
      1
      2
      3
      # 在连接 ssh 的时候,开启端口转发,将本地的 `5900` 端口转发到树莓派的 `5900` 端口
      # 实际上走的还是服务器的 `22` 端口
      ssh -L 5900:localhost:5900 <your_username>@<your_domain>
    4. 建立好 ssh 连接后,就可以在本地电脑上通过 localhost:5900 访问树莓派的 VNC 服务了(一定要注意,是连接 本地 localhost 不是服务器 5900 端口)

4. 使用体验和后续计划

使用体验

  1. 用外网可以直连树莓派,可以用 SSH 远程给树莓派下发一些下载任务,也可以用 VNC 处理一些需要 GUI 的需求
  2. 设置了移动硬盘开机自动挂载,索尼电视安装 KODI 通过 Samba 协议看 4K 电影体验很震撼

后续计划

  1. 开放 http 协议端口,在树莓派上用 Nginx 等引擎让树莓派作为个人博客的图床和视频床
  2. 扩展硬盘架,树莓派外接 16 pin PCIE 转多口 SATA 扩展板 ,连接机械硬盘架

5. 最终版效果

nas.jpg

  • 买了一个二手服务器电源 12v 供电,买了一个车载 12v5v 的电源模块,这两个一起给所有设备供电
  • 买了树莓派 m2SATA * 6 的扩展板,用 16pin PCIE 连到主板上
  • 硬盘架现在是两块 16T 服务器拆机机械硬盘 + 四块 500G 单片机械硬盘(凑数用的),后续会根据实际情况逐步把 500G 硬盘换成 16T 的服务器氦气盘
  • 因为供电电流充足(服务器电源可最高提供 50A 稳定电流),在不同时和多个硬盘交互的情况下,16T 硬盘读写速度差不多 260MB/s500G 硬盘读写速度差不多 100MB/s
  • 如果有小容量高速读写需求的话,主板上有一块 512GBFlash 可以用
  • 一堆风扇组成了一个小型的散热系统,电视柜当机箱用
  • 非常耐操,已经稳定运行半年多了,几乎没有关过机,随时随地访问私有云 + 家庭影院确实爽

URL

TL;DR

  • 本文提出一种名为 Low Rank Adaption (LoRA) 的大模型微调技术,可有效降低大模型微调过程中的可微调参数(降低 10000 倍)和显存占用(降低 3 倍)
  • 具体做法是在 Linear 算子和 Embedding 算子中插入可训练的参数量较少的低秩分解矩阵,冻结原始参数,只训练低秩分解矩阵

Algorithm

总体流程

lora_1.png

  • 这张图几乎包含了 LoRA 全部的信息量:
    1. LoRA 在原始 Linear 参数的基础上,加入了低秩分解矩阵 ARd×r,BRr×dA\in\mathbb{R}^{d\times r},B\in\mathbb{R}^{r\times d}
    2. r << d,所以叫
    3. 原始参数冻结,只训练 AB
    4. 矩阵 A 用均值为 0 的正态分布初始化
    5. 矩阵 B 用全 0 初始化

对应代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
import torch
import torch.nn as nn
class LoRALinear(nn.Module):
def __init__(self, in_features, out_features, r):
super(LoRALinear, self).__init__()
self.in_features = in_features
self.out_features = out_features
self.r = r
# 线性层的权重矩阵
self.weight = nn.Parameter(torch.randn(out_features, in_features))
self.bias = nn.Parameter(torch.zeros(out_features))
# LoRA的低秩分解矩阵
self.A = nn.Parameter(torch.randn(r, out_features))
self.B = nn.Parameter(torch.zeros(in_features, r))
def forward(self, x):
# 应用线性层
output = torch.matmul(x, self.weight) + self.bias
# 应用LoRA的低秩分解矩阵
output = output + torch.matmul(x, torch.matmul(self.B, self.A))
return output
def convert_to_standard_linear(self):
# 将LoRA参数转换到标准线性层中
self.weight = nn.Parameter(self.weight + torch.matmul(self.B, self.A))
# 删除LoRA的低秩分解矩阵
del self.A
del self.B
return self
class LoRATransformerLayer(nn.Module):
def __init__(self, d_model, r):
super(LoRATransformerLayer, self).__init__()
self.d_model = d_model
self.r = r
# 自注意力模块的权重矩阵
self.Wq = LoRALinear(d_model, d_model, r)
self.Wk = LoRALinear(d_model, d_model, r)
self.Wv = LoRALinear(d_model, d_model, r)
self.Wo = nn.Linear(d_model, d_model)
def forward(self, x):
# 计算查询、键和值的投影
q = self.Wq(x)
k = self.Wk(x)
v = self.Wv(x)
# 计算自注意力得分和输出
attn_scores = torch.matmul(q, k.transpose(-2, -1)) / (self.d_model**0.5)
attn_weights = torch.softmax(attn_scores, dim=-1)
attn_output = torch.matmul(attn_weights, v)
# 计算最终的输出
output = self.Wo(attn_output)
return output
def convert_to_standard_transformer(self):
# 将LoRA参数转换到标准Transformer网络中
self.Wq = self.Wq.convert_to_standard_linear()
self.Wk = self.Wk.convert_to_standard_linear()
self.Wv = self.Wv.convert_to_standard_linear()
return self
# 示例用法
d_model = 512
r = 8
layer = LoRATransformerLayer(d_model, r)
input_tensor = torch.randn(10, 32, d_model)
output_tensor = layer(input_tensor)
print(output_tensor.shape) # 输出: torch.Size([10, 32, 512])
# 转换到标准Transformer网络
standard_layer = layer.convert_to_standard_transformer()
print(standard_layer)

实际使用

  1. 实际使用时,可以用 LoRA_Layer替换大模型中所有 Transformer 层的 LinearEmbedding,对 FFN 中的 MLP 不做替换
  2. 可以不同的任务微调不同的 LoRA 模型
  3. 部署时可以用重参数化融合的方法,将 LoRA 训练参数融合到原始模型中,不会付出任何推理代价

Thought

  • 有道理,也挺好用,但据说真正做大模型预训练 / 微调的没人用

URL

TL;DR

  • 本文提出一种类似 GPT-4 的图文多模态模型 Large Language and Vision Assistant (LLaVA),基于开源的 CLIPLLaMA 分别作为图文编码器,因此 LLaVA 也完全开源

Algorithm

多模态指令遵循数据生成

  • 已有:图形——文本对数据集
  • 需要:图文指令遵循数据集,格式为:
    • 图片:原始图片
    • 问题:由 GPT-4 生成,输入原始 “图片-文本” 给 GPT-4,让 GPT-4 就这些信息提问
    • 答案:同上,让 GPT-4 回答自己提出的问题

模型结构

  • 图像模型:CLIP ViT-L/14 已做过图像文本对齐的预训练图像编码器模型
  • 大语言模型:LLaMA 预训练模型
  • 连接层:简单的线性映射层

如何训练和微调

训练

  • 冻结图像编码模型
  • 冻结 LLM 模型
  • 训练连接层

微调

  • 冻结图像编码模型
  • 训练 LLM 模型
  • 训练连接层
graph TD;
    A([视觉编码器]) --> B([连接层])
    B --> C([LLaMA语言模型])
    D[语言指令(例如:“请根据这张图片生成一个详细的描述”)] --> C
    C --> E[文本响应]
    F[图像] --> A
    G[系统消息(例如:对话历史记录)] --> C

Thought

  • 简单直接,来自开源,也回馈开源,很棒!

URL

TL;DR

  • 本文提出一种跨模态开放集目标检测算法,即:输入一张图片 和 需要检测内容的文本描述,给出框
  • 其中文本描述可以是开放的(任意内容的文本)
  • 本文最重要的部分是模型结构中图文多模态内容的融合

Algorithm

groundingdino.png

  • 本质是通过多次 Cross-Attention 来做多模态信息融合
  • text backbone 实际是 BERT
  • image backbone 实际是 SwinTransformer
  • 其中的 Language-guide Query Selection 是根据文本特征,找到图像特征中最匹配的部分初始化跨模态解码器

Thought

  • 这篇论文想要解决的任务时开放集目标检测,但其多模态信息融合方式让其出圈,成了多模态领域的经典

URL

TL;DR

  • DINO 一样,都是做自监督视觉预训练的,对于 DINO 的主要升级是构建了一个大规模自动化数据处理管线,构建了 LVD-142M 高质量数据集
  • 用这些数据集预训练了一个 1B 参数的 ViT 模型,通过无监督蒸馏的方式,得到用于不同任务的小模型

Algorithm

自动化数据处理管线

dino_v2.png

  • 自动化处理流程包括如下几个步骤,不断重复迭代

1. 数据收集

  • DINOv2 的数据源包括一个大型的未筛选图像数据集和一个较小的经过筛选的图像数据集
  • 未筛选数据集来自网络爬取
  • 筛选数据集来自 ImageNet-22k / Google Landmarks

2. 图像嵌入

  • 对于未筛选数据集,用一个训练好的 ViT-H/16 计算得到图像 embedding vector

3. 图像去重

  • 用特征空间下去重算法,将未筛选的数据集去重

4. 图像检索

  • 在特征空间下聚类,得到与筛选数据集类似的未筛选数据样本

5. 数据增强

  • 让这些聚类得到的类似的未筛选样本作为筛选样本,不断扩大筛选样本的数量和场景丰富度

模型架构

  • DINO v1 中教师和学生使用动量更新的方式不同,DINO v2 使用了常见的 “大老师,小学生” 架构
  • 先训练一个 1B 参数的 ViT 模型作为老师模型
  • 然后再在各个不同任务数据上蒸馏得到小模型

训练策略优化

  • 由于老师模型很大(1B 参数量),所以需要 LM 常用的训练加速方法,包括:
    • FlashAttention
    • Fully-shared Data Parallel (FSDP)

Thought

  • 这套数据处理管线是本文重点,所有的自监督任务,自动化数据处理流程都是必不可少的

URL

TL;DR

  • CLIPOpenAI 提出的一种图文多模态对齐算法,在收集到的 4 亿对图片文本数据对上,将文本和图像编码在同一表达空间下,实现了图文模态的对齐
  • 可以 zero-shot 迁移到其他计算机视觉任务上

Algorithm

CLIP.png

训练时

  1. N 对图片和文本各自编码
  2. 计算得到不同模态之间两两编码的 余弦相似度 RN×N\in \mathbb{R}^{N\times N}
  3. 使用对比学习的方式,提高 N 个正样本的相似度,降低剩余的 N2NN^2-N 个样本的相似度

推理时(以 ImageNet 分类任务为例)

  1. ImageNet-1k 的所有 1000 种类别标签,通过训练好的文本编码器,转换到特征空间中
  2. 将需要分类的图片,通过训练好的图片编码器,转换到特征空间中
  3. 图像编码找到余弦相似度最高的文本编码,对应的类别就是图片类别

模型选型

  • 图像编码器:
    • Vision Transformer (ViT)
    • ResNet-50
  • 文本编码器:Transformer
    • 63M 参数
    • 12
    • 512
    • 49152 词表大小
    • BPE 文本编码方式

Thought

  • 简洁高效,像 OpenAI 固有的风格
  • 有没有可能在 GPT-4 的多模态中用到呢?

URL

TL;DR

  • DINO(Distillation with No Labels) 是一种自监督学习方法,主要用于 Vision Transformer (ViT) 的训练
  • 在无标签的图片数据上训练模型,让模型学习图像的表示意义
  • 利用 MoCo 提出的 Momentum Teacher 算法做蒸馏

Algorithm

dino_1.png

训练流程

  1. 创建两个完全一样的网络,命名为教师 teacher 网络和学生 student 网络
  2. 对同一个输入 x,进行不同的数据增强,得到 x1x2
  3. 交叉计算对比损失,再求均值得到 loss for student
  4. 只对 student 网络进行反向传播和梯度更新
  5. 基于 student 网络的参数更新 teacher 的参数,更新方式是 EMA (exponential moving average),即:θt=λθt+(1λ)θs\theta_t=\lambda \theta_t+(1-\lambda)\theta_s
  6. 更新 teacher 网络输出的中心点:C=mC+(1m)mean(t1,t2)C = m*C + (1 - m)*mean(t1, t2)

中心化和锐化

dino.png

  • 两种操作本质上是互补的,防止模型训练崩溃

中心化(centering

  • 中心化的目的是防止特征向量的某个维度占主导地位,从而导致模型输出分布过于集中
  • 本质就是一种均值为 0 的归一化,可以提高模型训练的稳定性

锐化(Sharpening

  • 锐化操作的目的是增加教师网络输出的概率分布的锐度,使得输出的概率更加集中在少数几个维度上
  • 实现上,锐化通过修改蒸馏温度系数实现

模型效果

  • 比一众视觉自监督模型效果都好,比如:MoCo v1/v2SimCLR v1/v2

Thought

  • 感觉是 MoCo 系列的升级,框架本身不变,加了数据,稳定了训练过程,增加了些许 trick

URL

TL;DR

  • 本文提出一种提示词微调的方法,是对 P-Tuning 的升级
  • P-Tuning v2 要解决的问题是:对于所有(或大多数)任务类型和模型参数规模,将提示词微调的精度达到和整体参数微调同样的效果,这也是这篇论文的题目

Algorithm

p_tuning_v2.png

P-Tuning 的优化

1. P-Tuning 只在模型输入层添加可学习的连续嵌入,P-Tuning v2 在模型的每一层都添加

  1. Prefix Tuning / Prompt Tuning / P-Tuning 三种方法都是在模型输入中加入连续嵌入
    • 添加方式可能是前缀,也可能是其他 Concat Pattern
    • 通过 Self-Attentionembedding 间信息融合机制让虚拟连续嵌入影响整个模型
  2. P-Tuning V2 的做法则完全不同,是对模型的 每一层 都添加了可学习的虚拟连续嵌入
    • 具体来说是通过初始化虚拟 past_key_values 来实现的
    • GPT2-small 来举例(12transformer,每层 12 个头,每个头的 dim = 64
      • 假设 virtual_prompt_seq_len=3input_prompt_seq_len=10
      • 那么需要先初始化 past_key_valuesshape = [12, 2, 12, 3, 64],分别表示:
        • num_layers
        • key and value
        • num_heads
        • virtual_prompt_seq_len
        • dim
        • shape = [12, 2, 12, 3, 64] 以及修改后的输出层参数是可训练的所有参数
    • 然后将序列长度为 10input token embeddings 输入模型,第一层输出长度还是 10
    • 第二层以及之后的每一层都将上一层输出的长度为 10 的序列和长度为 3virtual_prompt_key_values 合并计算,并输出长度为 10 的序列

2. 不再使用 Verbalizer,而是使用 class head

  1. 什么是 Verbalizer ?
    • 传统的预训练语言模型(例如 Bert)的输入是一个 token 序列,输出是一个 token,也就是说词表中每个词都有可能输出
    • 现在有个下游任务需要用 Bert 做情感分类,输入是一段话,输出是:{正面,负面,中性} 中的一种,而且用 P-Tuning 方法微调,那么直接把输入附加上一些虚拟连续提示嵌入,输出的结果还是整个词表,不是三分类
    • 这时候就需要 Verbalizer 的存在了,它的作用是将 Bert 模型的输出从词表空间映射到三分类空间,它的实现形式可以是规则,也可以是深度学习模型
  2. P-Tuning V2 如何抛弃 Verbalizer?
    • 抛弃 Verbalizer 的方式很简单,就是打破 Prompt Tuning 模型时不应修改模型参数和结构 的限制
    • 直接删除预训练模型输出层,改成任务相关的层并随机初始化,然后微调

Thought

  • 看起来比 P-Tuning v2 更优雅,和 kv cache attention 结合起来,推理耗时增加较小
  • 据说对大模型来讲,这种方法和 Prompt Tuning 相比并没有显著精度优势(模型参数量小时,设计很重要;模型参数量大时,参数量几乎可以弥补一切设计上的非最优)

URL

TL;DR

  • 本文提出一种 Prompt Tuning 的方法 P-Tuning
  • Prefix TuningPrompt Tuning 这种连续词嵌入作为前缀的方法不同, P-Tuning 把连续词嵌入分段插入到 输入标签 之间

Algorithm

提出问题

  • 模型对人工设计的 Prompt 很敏感(指预训练模型,非大模型),同一个模型同一个数据集,只要稍微改变问题的问法,评测指标就差非常多,如图:
    p-tuning_1.png
  • P-Tuning 可解决此类问题

解决问题

  • 使用连续词嵌入(可训练)和离散词嵌入(不可训练)相结合的方法,做 Prompt Tuning 微调
    p-tuning_2.png
  • 上图左侧是传统全部用离散词嵌入 Prompt 过程
  • 上图右侧是离散词嵌入和连续词嵌入相结合的方法,其中 capticalBritain 两个问题中最关键的词使用离散词嵌入(来自于词表,固定不可训练),并在离散词嵌入周围插入若干连续词嵌入(可通过反向传播梯度下降训练)

数学表述

  • P-Tuning 中输入序列为 T={[P0:i],x,[P(i+1):j],y,[P(j+1),k]}T=\{[P_{0:i}],x,[P_{(i+1):j}],y,[P_{(j+1),k}]\},其中:
    • x 表示原始输入的离散词文本(还没有变成词向量)
    • y 表示原始的 label 文本
    • [P][P] 表示连续词向量
  • 输入序列 T 需要通过一种特殊的 Prompt Encoder 变成真实的词嵌入输入 {h0,...,hi,e(x),hi+1,...,hj,e(y),hj+1,...,hk}\{h_0,...,h_i,e(x),h_{i+1},...,h_j,e(y),h_{j+1},...,h_k\},其中:
    • e(x),e(y)e(x),e(y) 是通过查词表得到的离散词嵌入
    • hh 是通过 MLP/LSTM 等方法得到的连续向量的词嵌入,向量的长度和离散词嵌入一致

Thought

  • Prefix Tuning 插入连续词嵌入的自由度更高,因此理应效果更好,但总感觉解决问题的方法不优雅,因为离散和连续嵌入结合的模板是人为规定的,包含了较多先验知识在里面