《Hands-On Large Language Models》阅读笔记(一)

第一章:大语言模型简介

应该还是在前年读了 《Build a Large Language Model (From Scratch)》前面一小部分,就中断了, 如今又找来一本相关的书籍 《Hands-On Large Language Models》试着啃一啃,能明白多少是多少。拿着中英文的两个版本对照着看,有些中文翻译最好还是不译的好。 这里只会一些杂乱的笔记,算不得什么记要,重要的信息仍然在书上。

学习中相关的测试代码放在 GitHub 仓库 yabqiu/Hands-On-Large-Language-Models, 主要是在 macOS 苹果芯片上进行的测试。虽然官方有一个相应的仓库 HandsOnLLM/Hands-On-Large-Language-Models, 但作为沉浸式学习,亲自根据自己的口味撸一遍代码是非常必要的,所以也会发现我学习时的代码与书中不完全相同。

文字的计算机语义处理有过 Bag-of-words, word2vec, sequence-to-sequence, 再就是 BERT, GPT 这些概念了。 写作 "Attention Is All You Need" 这篇论文的作者们真是太伟大了,拿不到诺贝尔奖,也应该给他们个图灵奖,虽然其中的技术并非很高级, 但这篇论文在 AI 发展史中绝对有着里程碑式的意义。

模型从大的功能分为

  1. 仅编码的表示模型(representation model),如种种嵌入模型,都比较小的,约几百兆大小, 像 BERT(bidirectional encoder representations from Transformers)
  2. 生成模型(generative model),关注生成文本, 通常不会被训练用于生成嵌入, 像 GPT(Generative Pre-trained Transformer)

LLM 这个概念不仅指生成模型,也包括表示模型。生成式 LLM 就是一种 Seq2Seq 的文本生成系统,补全或者说猜测下一个 Token, 所以生成模型又称补全模型 (completion model), OpenAI V1 的 API 有一个是 /v1/chat/completions. 通过训练和微调可以做成指令模型 (instruct mode) 或对话模型(chat model). 指令型模型,不仅能补全,而是试图回答问题。

GPT-1 参数量 117M, GPT-2: 1.5B, GPT-3: 175B, 想想现在的非开源的 ChatGPT 5.5, Claude 4.7 的规模不知道会有多大了,好不容易在本地跑上一个 70B 的模型又如何。

上下文长度或窗口,当输入一段文字,模型每预测下一个 Token 后又会把它作为输入(自回归 - auto-regressive), 所以在生成新 Token 时当前上下文长度不断增加。

2023 年 ChatGPT(GPT-3.5) 的发布被称为 生成式 AI 元年, 又叫做 ChatGPT 时刻,除了最广泛的 Transformer 架构外,也有别的如 MambaRWKV.

LLM 现在更多是指生成式模型,最初步的预训练通常不针对特定的任务或应用,而仅用于预测下一个词,这样的模型称为基础模型或基座模型,这些模型通常不会遵循指令。

拿一个基座模型,针对具体任务进一步训练,能遵循指令,这叫微调,最花钱耗时的是基座模型的预训练. 基础模型和微调模型都属于预训练模型,这些名称有点乱。

特别是模型参数的表示法,一到中文中反而让人糊涂了,比如 3.8B 的参数,硬是要说成 38 亿参数,这个没法和国际接轨了,在这种特定行业的述语应该用 3.8B。 好吧,记住一个对应 10 亿一个 B,按这个关系转换。

下面是要先开始上手模型的使用,还不急着教授如何训练一个基础模型。要用到 Hugging Face 上的一个模型,Python 环境,先安装如下依赖,用 uv

uv add transformers torch accelerate

在 macOS 下,苹果芯片 M4 Pro,

 1from transformers import AutoModelForCausalLM, AutoTokenizer
 2
 3model_name = "microsoft/Phi-3-mini-4k-instruct"
 4
 5model = AutoModelForCausalLM.from_pretrained(
 6    model_name,
 7    torch_dtype="auto",
 8    device_map="mps",  # "cuda" if Nvidia, "cpu" if neither
 9)
10
11tokenizer = AutoTokenizer.from_pretrained(model_name)

运行正常的话会下载 7.6GB 的模型,好家伙,一上来就搞了一个大的

1hf cache ls
2ID                                  SIZE LAST_ACCESSED     LAST_MODIFIED  REFS
3----------------------------------- ---- ----------------- -------------- ----
4model/microsoft/Phi-3-mini-4k-in... 7.6G a few seconds ago 10 minutes ago main

模型文件目录在 ~/.cache/huggingface/hub, Hugging Face 网站的文件列表是 Phi-3-mini-4k-instruct, 本地跑模型就是太费硬盘,每个 LLM 客户端工具都有自己的格式与目录。

下载完后,加载执行,这里没任何输出,如果调试查看 tokenizer 的话,这个分词器的词汇大小(vocab_size) 是 32000,加上特殊字符就是 32011. 里面其他有意思的信息

1SPECIAL_TOKENS_ATTRIBUTES: ['bos_token', 'eos_token', 'unk_token', 'sep_token', 'pad_token', 'cls_token', 'mask_token']
2added_tokens_encoder: {'</s>': 2, '<s>': 1, '<unk>': 0, '<|assistant|>': 32001, '<|endoftext|>': 32000, '<|end|>': 32007, '<|placeholder1|>': 32002, '<|placeholder2|>': 32003, '<|placeholder3|>': 32004, '<|placeholder4|>': 32005, '<|placeholder5|>': 32008, '<|placeholder6|>': 32009, '<|system|>': 32006, '<|user|>': 32010}
3model_input_names: ['input_ids', 'attention_mask']
4special_tokens_map: {'bos_token': '<s>', 'eos_token': '<|endoftext|>', 'pad_token': '<|endoftext|>', 'unk_token': '<unk>'}
5vocab_file: '/Users/yanbin/.cache/huggingface/hub/models--microsoft--Phi-3-mini-4k-instruct/snapshots/f39ac1d28e925b323eae81227eaba4464caced4e/tokenizer.model'
6vocab_files_names: {'tokenizer_file': 'tokenizer.json', 'vocab_file': 'tokenizer.model'}
7model_max_length: 4096

继续实现像 LangChain 的一个 init_chat_model()

 1generator = pipeline(
 2    "text-generation",
 3    model=model,
 4    tokenizer=tokenizer,
 5    return_full_text=False,
 6    max_new_tokens=500,
 7    do_sample=False
 8)
 9
10output = generator([
11    {"role": "user", "content": "Create a funny joke about chickens."}
12])
13
14print(output[0]["generated_text"])
15# output: [{'generated_text': ' Why did the chicken join the band? Because it had the drumsticks!'}]

输出出为

Why did the chicken join the band? Because it had the drumsticks!

几个注意的参数

  • return_full_text: False 时只返回生成的文本,不返回输入的文本
  • max_new_tokens: 允许模型生成的最大 Token 数
  • do_sample: 决定模型是否使用采用策略来选择下一个 Token, False 时选择概率最高的 Token, 就是那个 temperature

模型分两大类,表示模型(仅编码, 如 BERT)与生成模型(仅解码, 如 GPT 系列),这两类都被视为 LLM, 通常面对终端用户的是生成模型。

第二章: Token 和 Embedding

TokensEmbeddings, 这两个词翻译成中文都怪怪的, 词元, 嵌入, 还是用英文吧。Token 就是 LLM 的词汇,Token 数字化即 Embbedding. Token embedding 一个著名的先驱就是 word2vec.

Tokenization 即通常的分词,OpenAI Platform tokenizer

看个 encode/decode 的例子,重用上面声明的 tokenizer, 补充下面的代码

 1tokenizer = AutoTokenizer.from_pretrained(model_name)
 2
 3prompt = "Write an email apologizing to Sarah for the tragic garding mishap. Explain ho it happened.<|assistant|>"
 4print(tokenizer.tokenize(prompt))
 5tokenized = tokenizer(prompt, return_tensors="pt").to("mps")
 6print(tokenized.input_ids)
 7
 8generate_ouput = model.generate(
 9    input_ids=tokenized.input_ids,
10    attention_mask=tokenized.attention_mask,
11    pad_token_id=tokenizer.eos_token_id,
12    max_new_tokens=20
13)
14
15print(tokenizer.decode(generate_ouput[0]))

每次执行都要重新加载 7.6GB 的模型到 macOS 的统一内存中,LLM 服务肯定不能这么干。看输出

1['▁Write', '▁an', '▁email', '▁apolog', 'izing', '▁to', '▁Sarah', '▁for', '▁the', '▁trag', 'ic', '▁gard', 'ing', '▁m', 'ish', 'ap', '.', '▁Exp', 'lain', '▁ho', '▁it', '▁happened', '.', '<|assistant|>']
2tensor([[14350,   385,  4876, 27746,  5281,   304, 19235,   363,   278, 25305,
3           293, 17161,   292,   286,   728,   481, 29889, 12027,  7420,  5089,
4           372,  9559, 29889, 32001]], device='mps:0')
5Write an email apologizing to Sarah for the tragic garding mishap. Explain ho it happened.<|assistant|> Subject: Sincere Apologies for the Gardening Mishap
6
7Dear Sarah

从这里可以看到输入是怎么被拆分的,有完整单词,也有单词的一部分,标点符号是个单独的 Token, 还有一些特殊的 Token, 例如 <|assistant|> 是一个特殊的 Token, 用于标识模型生成的文本的开始, 从 tokenizeradded_tokens_encoderadded_tokens_decoder 就能对 <|assistant|> 进行特殊处理, 除了 <|assistant|> 外,还有 <|user|>, <|system|>, 用过 LangChain 的是不是很熟悉啊,像 ToolMessage 等估计还能用 <|placeholder1|>

注意到空格不作为单独的 Token 看待,而是每个 Token 前都有一个 隐藏字符,有则是一个独立的词,无则与前面的 Token 连接在一起的。

分词器流行的有 BPE(byte pair encoding, 广泛用于 GPT 模型)和 WordPiece(用于 BERT 模型)。分词的粒度有以下几种

  1. 词级分词: 这种分词很自然,按空格切开,在早期的 word2vec 等模型很常见,但对前后缀不同 Token, 例如 play, playing, played 等, 词级分词会把它们当成不同的 Token, 导致词汇表过大, 模型难以学习到它们之间的关系.
  2. 子词级分词: 解决了上面的前后缀的问题,例如上面只要训练 play, 后缀 ing, ed 则可与多数动词组合, 而且还能创造新词。
  3. 字符级分词和字节级分词: play 按拆成 p, l, a, y 四个 Token, 或者按 unicode 拆成单个字节, 这就脱离的语义,不知在什么模型中用到

以下是不同分词器产生的效果比较

 1from transformers import AutoTokenizer
 2
 3colors_list = [
 4    '102;194;165', '252;141;98', '141;160;203',
 5    '231;138;195', '166;216;84', '255;217;47'
 6]
 7
 8def show_tokens(sentence, tokenizer_name):
 9    print(f"------{tokenizer_name=}------")
10    tokenizer = AutoTokenizer.from_pretrained(tokenizer_name)
11    token_ids = tokenizer(sentence).input_ids
12    for idx, t in enumerate(token_ids):
13        token =  tokenizer.decode(t)
14        if token == "\n":
15            print('\r')
16        else:
17            print(
18                f'\x1b[0;30;48;2;{colors_list[idx % len(colors_list)]}m' +
19                tokenizer.decode(t) +
20                '\x1b[0m',
21                end=' '
22            )
23    print("")
24
25
26text = """
27English and CAPITALIZATION
28🎵 鸟
29show_tokens False None elif == >= else: two tabs:"      " Three tabs: "         "
3012.0*50=600
31"""
32
33show_tokens(text, "bert-base-uncased") # WordPiece, 词汇大小 30522
34show_tokens(text, "bert-base-cased")   # WordPiece, 词汇大小 28996
35show_tokens(text, "gpt2")              # BPE, 词汇大小 50257
36show_tokens(text, "google/flan-t5-small") # SentencePiece, 词汇大小 32100
37show_tokens(text, "Xenova/gpt-4")         # BPE, 词汇大小略多于 100,000
38show_tokens(text, "bigcode/starcoder2-15b") # BPE, 词汇大小 50,257
39show_tokens(text, "facebook/galactica-1.3b") # BPE, 词汇大小 50,000
40show_tokens(text, "microsoft/Phi-3-mini-4k-instruct") # BPE, 词汇大小 32,000

效果图

执行完后本地有了以下几个模型,分词器非常的小

 1hf cache list
 2ID                                    SIZE LAST_ACCESSED  LAST_MODIFIED  REFS
 3----------------------------------- ------ -------------- -------------- ----
 4model/Xenova/gpt-4                    7.2M 21 minutes ago 21 minutes ago main
 5model/bert-base-cased               649.9K 22 minutes ago 22 minutes ago main
 6model/bert-base-uncased             698.2K 26 minutes ago 26 minutes ago main
 7model/facebook/galactica-1.3b         2.1M 21 minutes ago 21 minutes ago main
 8model/google/flan-t5-small            3.2M 22 minutes ago 22 minutes ago main
 9model/gpt2                            2.9M 22 minutes ago 22 minutes ago main
10model/microsoft/Phi-3-mini-4k-in...   7.6G 13 hours ago   15 hours ago   main
  • bert-base-uncased, bert-base-cased: 分别为大小写不敏感与敏感的 bert,不敏感则全转换成小写,不认识的标识为 [UNK], ## 特殊字符表示是前面是否有空格。
  • gpt2, Xenova/gpt-4: 不认识的显示为问号,Token 前自带空格表示是否与前面相连。gpt2 一个 tab 对应一个 token, 而 Xenova/gpt-4 一个 token 可表示多个空白,比如适于生成 Python 代码. gpt-4 中有自己的 elif token

注意一些特殊的 Token, 象 [UNK]: unknown, [SEP]: 段落分割, [PAD]: 填充,[CLS]: 分类,classification, [MASK]: 训练过程中用于隐藏 Token,猜猜猜用的。不同分词器会有自己特殊 Token, 如 Phi-3<|user|>, <|system|>, <|assistant|> 用于 ChatBot 的.

分词中包含的 Token 是由三个主要因素决定的:分词方法,用于初始化分词器的参数, 以及训练分词器的目标数据所在的领域。分词方法最常用就是 BPE, 分词器参数包括词表大小,特殊 Token, 大小写处理策略。

Token Embedding, 词分好了,这样语言变成成 Token 序列了,要为每个 Token 找到最佳的数值表示,这样才能被计算。一个 Token 并不对应一个标量值, 而变成一个向量,每个分量值就是权重值,开始随机初始化,训练模型就是调教这些权重值。

测试 Embedding

 1from transformers import AutoModel, AutoTokenizer
 2
 3tokenizer = AutoTokenizer.from_pretrained("microsoft/deberta-base")
 4model = AutoModel.from_pretrained("microsoft/deberta-v3-xsmall")
 5
 6tokens = tokenizer('Hello world')
 7for token in tokens.input_ids:
 8    print(tokenizer.decode(token), ':', token)
 9
10tokens = tokenizer('Hello world', return_tensors="pt")
11
12output = model(**tokens)
13
14print(output)
15print(output[0].shape)

观察上面的输出

 1[CLS] : 1
 2Hello : 31414
 3 world : 232
 4[SEP] : 2
 5BaseModelOutput(last_hidden_state=tensor([[[-3.4844,  0.0862, -0.1819,  ..., -0.0612, -0.3904,  0.3018],
 6         [ 0.1899,  0.3203, -0.2313,  ...,  0.3726,  0.2478,  0.8022],
 7         [ 0.2070,  0.5029, -0.0490,  ...,  1.2188, -0.2271,  0.8574],
 8         [-3.4277,  0.0643, -0.1427,  ...,  0.0659, -0.4363,  0.3845]]],
 9       dtype=torch.float16, grad_fn=<NativeLayerNormBackward0>), hidden_states=None, attentions=None)
10torch.Size([1, 4, 384])

Hello world 拆成 4 个 Token, 每个 Token 嵌入到一个包含 384 个数值的向量中(这种情景下 嵌入 的意义就表达出来的)。

output[0] 有三个维度,第一个维度是 batch(同时向模型发送多个句子), 第二个维度对应每一个 Token,第三个维度是每个 Token 的向量。

batch, token, token embedding 用一张图来表示

这是 Token Embeddings, 一个 Token 对应的一个向量,LLM 还有 Text Embeddings 的概念,用单个向量来表示多个 Token 组成的文本,又想到了 RAG. RAG 的文本资料切分和向量化就是做的文本嵌入,一个向量表示一个文本切片。生成文本嵌入有多种方法,常见方法之一就是对每个 Token Embedding 向量取平均值, 组成一个新的向量。高质量的文本嵌入要使用专门为文本嵌入训练的模型。

1from sentence_transformers import SentenceTransformer
2
3model = SentenceTransformer("sentence-transformers/all-mpnet-base-v2")
4vector = model.encode("Best movie ever!")
5
6print(vector.shape)  # (768,)

上面代码把 Best movie ever! 嵌入到了一个维度为 768 的向量中。

Token Embeddings 除了在文本和语言生成方面有用,还应用于其他许多领域,如推荐引擎,机器人技术。下面来学习 word2vec.

1import gensim.downloader as api
2
3# 下载 Embedding (66M, glove,训练数据来自维基百科,向量大小:50)
4# 其他选项如: 'word2vec-google-news-300', 可查看 https://github.com/piskvorky/gensim-data
5model = api.load("glove-wiki-gigaword-50")
6
7similarities = model.most_similar([model['king']], topn=5)
8for word, score in similarities:
9    print(f"{word:<10} {score:.4f}")

找相似的 Token,找到

1king       1.0000
2prince     0.8236
3queen      0.7839
4ii         0.7746
5emperor    0.7736

word2vec 有两种训练方式, skip-gramCBOW(Continuous Bag of Words) 来训练用一个词预测它周围的词,Skip-gram 输入中心词输出周围的词, CBOW 相反,输入周围的词预测中心词。word2vec 的两个主要思想是 skip-gram 和负采样(negative sampling),

word2vec 是用滑动容器来训练一个词来预测它周围的词,最后也是把 Token 嵌入到一个向量中,最终训练的是不断的优化嵌入向量中的权重值, 用以预测两个向量之间是否具有某种关系。

最后是一个训练歌曲推荐系统的实例,

完整代码

 1import gzip
 2
 3import pandas as pd
 4from pathlib import Path
 5
 6import time
 7from gensim.models import Word2Vec
 8
 9current_folder = Path(__file__).parent
10
11# https://storage.googleapis.com/maps-premium/dataset/yes_complete/train.txt
12with gzip.open(current_folder.joinpath('train.txt.gz'), 'rt') as f:
13    lines = f.readlines()[2:]
14    playlist = [s.rstrip().split() for s in lines if len(s.split())>1]
15    print(f"number of playlist: {len(playlist)}")
16    for ids in playlist[:2]:
17        print(f"number of song in this playlist: {len(ids)}: {str(ids[:10]).rstrip(']')}, ....")
18
19total_songs = {id for ids in playlist for id in ids}
20print(f"total songs: {len(total_songs)}")
21
22# https://storage.googleapis.com/maps-premium/dataset/yes_complete/song_hash.txt
23with gzip.open(current_folder.joinpath('song_hash.txt.gz'), 'rt') as f:
24    lines = f.readlines()
25    songs = [[field.strip() for field in s.split('\t')] for s in lines]
26    songs_df = pd.DataFrame(songs, columns=['id', 'title', 'artist'])
27    songs_df = songs_df.set_index('id')
28    print(f"songs_df shape: {songs_df.shape}")
29    print(f"songs_df sample: \n {songs_df.head()}")
30
31time0 = time.time()
32model = Word2Vec(sentences=playlist, vector_size=32, window=20, min_count=1, workers=4)
33print(f"training time: {time.time() - time0:.2f}s")
34print(f"model parameters: {model.wv.vectors.shape}\n")
35
36print(f"model parameters: {model.wv.vectors.shape}")
37
38def recommend_songs(song_id: str, topn=5):
39    similarities = model.wv.most_similar(positive=song_id, topn=topn)
40
41    print(f"similar songs of [{songs_df.loc[song_id]['title']}]:\n")
42
43    print(f"{'No':<2} {'Title':<30} {'Score'}")
44    for idx, (similar_id, score) in enumerate(similarities):
45        print(f"{idx+1:<2} {songs_df.loc[similar_id]['title']:<30} {score:.4f}")
46
47recommend_songs('2172')
48print(f"\n{"*" * 50}\n")
49recommend_songs('21720', 2)

输出如下

 1number of playlist: 11088
 2number of song in this playlist: 97: ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', ....
 3number of song in this playlist: 205: ['78', '79', '80', '3', '62', '81', '14', '82', '48', '83', ....
 4total songs: 75261
 5songs_df shape: (75262, 2)
 6songs_df sample:
 7                                                 title      artist
 8id
 90                        Gucci Time (w\/ Swizz Beatz)  Gucci Mane
101   Aston Martin Music (w\/ Drake & Chrisette Mich...   Rick Ross
112                       Get Back Up (w\/ Chris Brown)        T.I.
123                  Hot Toddy (w\/ Jay-Z & Ester Dean)       Usher
134                                        Whip My Hair      Willow
14training time: 3.03s
15model parameters: (75261, 32)
16
17similar songs of [Fade To Black]:
18
19No Title                          Score
201  Signs                          0.9947
212  Mama I'm Coming Home           0.9945
223  Highway To Hell                0.9939
234  November Rain                  0.9939
245  Flying High Again              0.9934
25
26**************************************************
27
28similar songs of [Flor Sin Retono]:
29
30No Title                          Score
311  Maruja                         0.9908
322  Amargo Adios                   0.9895

这是在做什么呢?从 "train.txt.gz" 读出来的每一行是一个播放列表,共有 11088 个列表,播放列表中是每首歌的 ID, 该文件中一共有 75261 个唯一的 ID. 在训练时每一行就是一句话,一个 ID 是一个 Token, 经常出现在一个播放列表,并且离得越近的 ID 越相似。

看关键的用于训练的代码

1model = Word2Vec(sentences=playlist, vector_size=32, window=20, min_count=1, workers=4)

训练的数据集就是 playlist, 所有歌的 ID 组成了一个词汇表(从 playlist 中抽取, 大小为 75261),每个 ID(Token) 嵌入到一个维度为 32 的向量中, 滑动容器大小为 20, 只关注与当前 ID(Token) 前后距离 20 以内的相关歌曲,四个 workers 进行并行训练,

训练后会产生一个形状为 (75261, 32) 的 model.wv.vectors 权重值,即模型参数,训练好了就能用余弦相似度匹配歌曲进行推荐了。向量索引与 Token 之间的对应关系存储在 model.wv.index_to_keymodel.wv.key_to_index.

为了不草草结束,这里也加一个书上第二章的学习小结,分词就是把文本转换为 Token Id, 常见的分词方案应该是子词分解。分词器算法(如 BPE, WordPiece, SentencePiece) 要考虑的参数有词表大小,特殊 Token, 大小写处理,以及应用领域等因素,如空白,多连续空格的表示。 分词器为不同的应用领域会引入不同的特殊 Token, 如 <|user|>, <|assistant|>, <|system|> 用于 ChatBot 的. 用一个 Token 可表示连续的几个空格,方便于编程代码的输出。

在 LLM 之前, word2vec, GloVe 和 fastText 等词嵌入方法非常流行,现在为 LLM 的上下文相关的词嵌入所取代。除了词嵌入(Token Embeddings), 用于训练, 还有文本嵌入(Text Embeddings),在 RAG 中使用。work2vec 算法依赖两个主要思想: skip-gram 模型和负采样。

永久链接 https://yanbin.blog/hands-on-large-language-models-reading-notes-1/, 来自 隔叶黄莺 Yanbin's Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。