第二章:NLP核心逻辑
本章学习目标
- 理解自然语言处理的基本流程和核心挑战
- 掌握词嵌入技术的原理与实现(Word2Vec、GloVe、FastText)
- 理解从上下文无关到上下文相关表示的演进逻辑
- 掌握语言模型从N-gram到神经语言模型的发展脉络
- 深入理解Seq2Seq、注意力机制、预训练-微调范式
- 掌握提示工程和上下文学习的核心原理
- 了解NLP评估指标、模型压缩技术和伦理考量
2.1 自然语言处理基础
2.1.1 文本预处理与分词技术
概念引入:为什么需要文本预处理?
自然语言文本是非结构化数据 ,包含大量噪声(标点符号、特殊字符、拼写错误等)。计算机无法直接理解原始文本,需要将其转换为结构化表示。文本预处理就是将原始文本转换为模型可处理的数值化表示的第一步。
类比理解:想象你在阅读一本外文书籍,首先需要将文字识别出来(分词),然后理解每个词的含义(嵌入),最后才能理解整句话的意思(语义表示)。文本预处理就是完成"识别文字"这一步。
文本预处理流程
完整的文本预处理通常包括以下步骤:
原始文本 → 清洗(去除HTML标签、特殊字符) → 分词 → 去除停用词 → 词干提取/词形还原 → 向量化
1. 文本清洗
去除文本中的噪声信息:
- HTML标签:
<p>Hello</p>→Hello - 特殊字符:
Hello, World!→Hello World - 数字处理:
I have 100 dollars→I have NUM dollars(可选) - 小写化:
Hello→hello(英文场景)
2. 分词(Tokenization)
将文本分割为最小的语义单元(词或子词)。
-
英文分词:相对简单,通常按空格分割
"I love natural language processing" → ["I", "love", "natural", "language", "processing"] -
中文分词:复杂得多,因为中文没有显式的词边界
"我喜欢自然语言处理" → ["我", "喜欢", "自然语言", "处理"] # 结巴分词 → ["我", "喜欢", "自然", "语言", "处理"] # 另一种分词结果
3. 停用词去除
去除高频但语义贡献小的词(如"的"、"了"、"is"、"the"等)。
4. 词干提取(Stemming)与词形还原(Lemmatization)
-
词干提取:去除词缀,得到词根(可能不是完整单词)
"running" → "run" "flies" → "fli" # 词干提取可能过度截断 -
词形还原:将词还原为词典中的基本形式(更准确)
"running" → "run" "flies" → "fly" # 动词"飞",而非名词"苍蝇"
python
# ===== 代码实战2.1:使用NLTK和jieba进行文本预处理 =====
import re
import jieba
import nltk
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer, WordNetLemmatizer
from typing import List
# 下载NLTK所需资源(首次运行时需要)
try:
nltk.data.find('corpora/stopwords')
except LookupError:
nltk.download('stopwords')
nltk.download('wordnet')
nltk.download('punkt')
class TextPreprocessor:
"""
文本预处理器:支持中英文预处理
"""
def __init__(self, language='english'):
"""
初始化预处理器
Args:
language: 语言类型,'english' 或 'chinese'
"""
self.language = language
if language == 'english':
# 加载英文停用词
self.stop_words = set(stopwords.words('english'))
# 初始化词干提取器和词形还原器
self.stemmer = PorterStemmer()
self.lemmatizer = WordNetLemmatizer()
elif language == 'chinese':
# 加载结巴分词
jieba.initialize()
# 可自定义中文停用词表
self.stop_words = set([
'的', '了', '在', '是', '我', '有', '和', '就', '不', '人',
'都', '一', '一个', '上', '也', '很', '到', '说', '要', '去', '你'
])
def clean_text(self, text: str) -> str:
"""
清洗文本:去除HTML标签、特殊字符等
Args:
text: 原始文本
Returns:
清洗后的文本
"""
# 去除HTML标签
text = re.sub(r'<[^>]+>', '', text)
# 去除URL
text = re.sub(r'http[s]?://\S+', '', text)
# 去除特殊字符(保留字母、数字、中文、基本标点)
if self.language == 'english':
text = re.sub(r'[^a-zA-Z0-9\s]', '', text)
else: # 中文
text = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9\s]', '', text)
# 小写化(仅英文)
if self.language == 'english':
text = text.lower()
# 去除多余空格
text = re.sub(r'\s+', ' ', text).strip()
return text
def tokenize_english(self, text: str) -> List[str]:
"""
英文分词
Args:
text: 英文文本
Returns:
分词结果
"""
# 使用NLTK分词
tokens = nltk.word_tokenize(text)
return tokens
def tokenize_chinese(self, text: str) -> List[str]:
"""
中文分词(使用结巴分词)
Args:
text: 中文文本
Returns:
分词结果
"""
# 精确模式分词
tokens = jieba.lcut(text, cut_all=False)
return tokens
def remove_stopwords(self, tokens: List[str]) -> List[str]:
"""
去除停用词
Args:
tokens: 分词结果
Returns:
去除停用词后的结果
"""
return [token for token in tokens if token not in self.stop_words]
def lemmatize_english(self, tokens: List[str]) -> List[str]:
"""
英文词形还原
Args:
tokens: 英文分词结果
Returns:
词形还原后的结果
"""
return [self.lemmatizer.lemmatize(token) for token in tokens]
def preprocess(self, text: str, use_lemmatization=True, remove_stop=True) -> List[str]:
"""
完整的预处理流程
Args:
text: 原始文本
use_lemmatization: 是否使用词形还原(仅英文)
remove_stop: 是否去除停用词
Returns:
预处理后的分词结果
"""
# 1. 清洗文本
text = self.clean_text(text)
# 2. 分词
if self.language == 'english':
tokens = self.tokenize_english(text)
else:
tokens = self.tokenize_chinese(text)
# 3. 去除停用词
if remove_stop:
tokens = self.remove_stopwords(tokens)
# 4. 词形还原(仅英文)
if self.language == 'english' and use_lemmatization:
tokens = self.lemmatize_english(tokens)
return tokens
# 测试文本预处理
def test_text_preprocessing():
"""测试中英文文本预处理"""
# 英文示例
print("=== 英文文本预处理 ===")
english_text = "<p>Hello, World! I am running in the park.</p>"
print(f"原始文本: {english_text}")
en_processor = TextPreprocessor(language='english')
en_tokens = en_processor.preprocess(english_text)
print(f"预处理结果: {en_tokens}")
# 中文示例
print("\n=== 中文文本预处理 ===")
chinese_text = "我喜欢自然语言处理技术!"
print(f"原始文本: {chinese_text}")
zh_processor = TextPreprocessor(language='chinese')
zh_tokens = zh_processor.preprocess(chinese_text)
print(f"预处理结果: {zh_tokens}")
if __name__ == "__main__":
test_text_preprocessing()
子词分词(Subword Tokenization)
传统分词方法存在OOV(Out-of-Vocabulary)问题------无法处理词汇表外的词。子词分词通过将罕见词拆分为更小的子词单元来解决这个问题。
主流子词分词算法:
| 算法 | 原理 | 示例 | 使用模型 |
|---|---|---|---|
| BPE(Byte Pair Encoding) | 迭代合并高频字符对 | playing → play + ##ing |
GPT、BERT(WordPiece变体) |
| WordPiece | 基于似然度合并 | unhappy → un + ##happy |
BERT |
| SentencePiece | 直接处理原始文本(含空格) | 统一处理中英文 | ALBERT、XLNet、T5 |
python
# ===== 代码实战2.2:使用Hugging Face Tokenizer进行子词分词 =====
from transformers import BertTokenizer, GPT2Tokenizer
def demonstrate_subword_tokenization():
"""演示BERT和GPT的子词分词策略"""
# 加载预训练分词器
bert_tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
gpt2_tokenizer = GPT2Tokenizer.from_pretrained('gpt2')
# 测试句子
text = "I am unhappy about the preprocessing step."
print("=== BERT WordPiece分词 ===")
bert_tokens = bert_tokenizer.tokenize(text)
print(f"原始文本: {text}")
print(f"BERT分词结果: {bert_tokens}")
print(f"说明: 'unhappy'被拆分为 '[CLS]', 'un', '##happy'")
print("\n=== GPT-2 BPE分词 ===")
gpt2_tokens = gpt2_tokenizer.tokenize(text)
print(f"原始文本: {text}")
print(f"GPT-2分词结果: {gpt2_tokens}")
print(f"说明: GPT使用BPE,'unhappy'可能被保留或拆分")
# 中文分词示例
print("\n=== 中文BERT分词 ===")
chinese_text = "我喜欢自然语言处理"
chinese_tokens = bert_tokenizer.tokenize(chinese_text)
print(f"原始文本: {chinese_text}")
print(f"分词结果: {chinese_tokens}")
print(f"说明: 中文BERT通常使用字符级或子词级分词")
demonstrate_subword_tokenization()
2.1.2 词嵌入技术
概念引入:从独热编码到词嵌入
**独热编码(One-Hot Encoding)**是最直观的词表示方法:
"cat" → [1, 0, 0, 0, 0, ...] # 维度 = 词汇表大小(可能超过100,000)
"dog" → [0, 1, 0, 0, 0, ...]
"apple" → [0, 0, 1, 0, 0, ...]
独热编码的缺陷:
- 维度灾难:词汇表大小可能达到10万+,导致特征维度极高
- 无法捕捉语义关系:任意两个词的独热向量点积为0(完全不相关)
- 稀疏性:绝大多数元素为0,计算效率低
词嵌入(Word Embedding)通过将词映射到低维连续向量空间(通常50~300维),解决了上述问题。
类比理解:独热编码就像用身份证号表示一个人------每个人有唯一的号码,但号码之间没有任何关系。词嵌入就像用"性格特征向量"表示一个人------相近的人有相同的特征,可以通过特征向量的距离来判断相似度。
Word2Vec
Word2Vec由Mikolov等人于2013年提出,包含两个模型架构:
1. Skip-gram模型
目标:给定中心词,预测其上下文词。
数学原理:
对于给定的中心词 w t w_t wt,Skip-gram最大化以下对数似然:
L skip-gram = 1 T ∑ t = 1 T ∑ − c ≤ j ≤ c , j ≠ 0 log p ( w t + j ∣ w t ) \mathcal{L}{\text{skip-gram}} = \frac{1}{T}\sum{t=1}^{T}\sum_{-c\leq j\leq c, j\neq 0}\log p(w_{t+j}|w_t) Lskip-gram=T1t=1∑T−c≤j≤c,j=0∑logp(wt+j∣wt)
其中 c c c 为上下文窗口大小, T T T 为文本长度。
条件概率 p ( w O ∣ w I ) p(w_O|w_I) p(wO∣wI) 通过Softmax计算:
p ( w O ∣ w I ) = exp ( v w O ′ T v w I ) ∑ w = 1 W exp ( v w ′ T v w I ) p(w_O|w_I) = \frac{\exp(\mathbf{v}{w_O}^{\prime T} \mathbf{v}{w_I})}{\sum_{w=1}^{W}\exp(\mathbf{v}{w}^{\prime T} \mathbf{v}{w_I})} p(wO∣wI)=∑w=1Wexp(vw′TvwI)exp(vwO′TvwI)
其中 v w \mathbf{v}_w vw 为输入词向量, v w ′ \mathbf{v}_w' vw′ 为输出词向量, W W W 为词汇表大小。
2. CBOW(Continuous Bag-of-Words)模型
目标:给定上下文词,预测中心词。
计算效率:Skip-gram适合小数据集,CBOW适合大数据集。实际应用中,Skip-gram更常用,因为它能更好地处理罕见词。
负采样(Negative Sampling):
直接优化Softmax计算代价太高( O ( W ) O(W) O(W))。负采样通过将其转化为二分类问题来加速训练:
L neg = − log σ ( v w O ′ T v w I ) − ∑ i = 1 K log σ ( − v w i ′ T v w I ) \mathcal{L}{\text{neg}} = -\log\sigma(\mathbf{v}{w_O}^{\prime T}\mathbf{v}{w_I}) - \sum{i=1}^{K}\log\sigma(-\mathbf{v}{w_i}^{\prime T}\mathbf{v}{w_I}) Lneg=−logσ(vwO′TvwI)−i=1∑Klogσ(−vwi′TvwI)
其中 K K K 为负样本数量(通常 K = 5 ∼ 20 K=5\sim20 K=5∼20)。
python
# ===== 代码实战2.3:使用Gensim训练Word2Vec模型 =====
from gensim.models import Word2Vec
from gensim.models.word2vec import Text8Corpus
import numpy as np
def train_word2vec_model():
"""训练Word2Vec模型并展示词向量特性"""
# 构造示例语料库
sentences = [
["I", "love", "natural", "language", "processing"],
["Word2Vec", "is", "a", "great", "algorithm"],
["I", "love", "machine", "learning"],
["Natural", "language", "processing", "is", "part", "of", "AI"],
["Machine", "learning", "is", "also", "part", "of", "AI"]
]
print("=== 训练Word2Vec模型 ===")
# 训练Skip-gram模型
model_skipgram = Word2Vec(
sentences=sentences,
vector_size=100, # 词向量维度
window=5, # 上下文窗口大小
min_count=1, # 最小词频(保留所有词)
negative=15, # 负采样数量
sg=1, # sg=1表示Skip-gram,sg=0表示CBOW
epochs=100, # 训练轮数
seed=42 # 随机种子
)
print(f"词汇表大小: {len(model_skipgram.wv.key_to_index)}")
print(f"词向量维度: {model_skipgram.vector_size}")
# 查看词向量
print("\n=== 词向量示例 ===")
love_vector = model_skipgram.wv['love']
print(f"'love'的词向量 (前10维): {love_vector[:10]}")
# 查找相似词
print("\n=== 相似词查找 ===")
similar_words = model_skipgram.wv.most_similar('love', topn=3)
for word, score in similar_words:
print(f" {word}: {score:.4f}")
# 词向量类比推理(需足够大的语料库才能生效)
print("\n=== 词向量类比推理 ===")
try:
result = model_skipgram.wv.most_similar(
positive=['natural', 'processing'],
negative=['language'],
topn=1
)
print(f"natural + processing - language ≈ {result[0][0]}")
except:
print(" 注: 示例语料库太小,无法展示准确的类比推理")
return model_skipgram
def visualize_word_vectors(model):
"""可视化词向量(使用PCA降维)"""
import matplotlib.pyplot as plt
from sklearn.decomposition import PCA
# 获取所有词的向量
words = list(model.wv.key_to_index.keys())[:10] # 取前10个词
vectors = np.array([model.wv[word] for word in words])
# PCA降维到2维
pca = PCA(n_components=2)
vectors_2d = pca.fit_transform(vectors)
# 绘制
plt.figure(figsize=(10, 8))
plt.scatter(vectors_2d[:, 0], vectors_2d[:, 1], s=100)
for i, word in enumerate(words):
plt.annotate(word, (vectors_2d[i, 0], vectors_2d[i, 1]), fontsize=12)
plt.title('Word2Vec词向量可视化(PCA降维)', fontsize=14)
plt.grid(True, alpha=0.3)
plt.tight_layout()
plt.savefig('word2vec_visualization.png', dpi=150)
plt.show()
if __name__ == "__main__":
model = train_word2vec_model()
visualize_word_vectors(model)
GloVe(Global Vectors for Word Representation)
GloVe由Pennington等人于2014年提出,结合了全局矩阵分解 和局部上下文窗口的优点。
核心思想 :利用词共现矩阵(Co-occurrence Matrix)中的统计信息。
数学原理:
定义 X i j X_{ij} Xij 为词 j j j 在词 i i i 的上下文中出现的频次。GloVe最小化以下目标函数:
J = ∑ i , j = 1 W f ( X i j ) ( w i T w ~ j + b i + b ~ j − log X i j ) 2 J = \sum_{i,j=1}^{W}f(X_{ij})(\mathbf{w}_i^T \tilde{\mathbf{w}}_j + b_i + \tilde{b}j - \log X{ij})^2 J=i,j=1∑Wf(Xij)(wiTw~j+bi+b~j−logXij)2
其中:
- w i , w ~ j \mathbf{w}_i, \tilde{\mathbf{w}}_j wi,w~j 分别为词 i i i 和词 j j j 的词向量
- b i , b ~ j b_i, \tilde{b}_j bi,b~j 为偏置项
- f ( X i j ) f(X_{ij}) f(Xij) 为加权函数,用于限制高频词共现的影响:
f ( x ) = { ( x / x max ) α , x < x max 1 , x ≥ x max f(x) = \begin{cases} (x/x_{\max})^{\alpha}, & x < x_{\max} \\ 1, & x \geq x_{\max} \end{cases} f(x)={(x/xmax)α,1,x<xmaxx≥xmax
通常 x max = 100 , α = 0.75 x_{\max} = 100, \alpha = 0.75 xmax=100,α=0.75。
Word2Vec vs GloVe:
- Word2Vec基于预测(预测上下文词),训练速度快
- GloVe基于统计(全局共现矩阵),利用全局信息,对小数据集效果更好
- 实践中两者性能相近,选择取决于具体任务和数据规模
FastText
FastText由Bojanowski等人于2017年提出,是Word2Vec的扩展,将每个词表示为其字符n-gram的和。
核心创新:
对于词 w w w,其词向量为:
v w = ∑ g ∈ G w z g \mathbf{v}w = \sum{g \in G_w} \mathbf{z}_g vw=g∈Gw∑zg
其中 G w G_w Gw 为词 w w w 的所有字符n-gram集合。
示例:
- 词
where,使用3-gram:<wh,whe,her,ere,re>(<和>表示词边界) - 词向量 = 所有n-gram向量的和
优势:
- 解决OOV问题:未知词可以通过其字符n-gram构建词向量
- 捕捉形态学信息 :词根相同的词(如
run,running,runner)共享n-gram表示 - 适合形态丰富的语言:如土耳其语、芬兰语等
python
# ===== 代码实战2.4:使用FastText训练词嵌入 =====
from gensim.models import FastText
def train_fasttext_model():
"""训练FastText模型并展示其处理OOV词的能力"""
# 构造示例语料库
sentences = [
["I", "love", "natural", "language", "processing"],
["FastText", "can", "handle", "out", "of", "vocabulary", "words"],
["Character", "n", "grams", "are", "powerful"],
["I", "love", "machine", "learning"],
["Natural", "language", "processing", "is", "part", "of", "AI"]
]
print("=== 训练FastText模型 ===")
# 训练FastText模型
model_fasttext = FastText(
sentences=sentences,
vector_size=100,
window=5,
min_count=1,
epochs=100,
seed=42
)
print(f"词汇表大小: {len(model_fasttext.wv.key_to_index)}")
# 查找相似词
print("\n=== 相似词查找 ===")
similar_words = model_fasttext.wv.most_similar('love', topn=3)
for word, score in similar_words:
print(f" {word}: {score:.4f}")
# FastText的杀手级特性:处理OOV词
print("\n=== FastText处理OOV词 ===")
oov_word = "loved" # 不在训练语料中
try:
# FastText可以为OOV词生成词向量(基于字符n-gram)
oov_vector = model_fasttext.wv[oov_word]
print(f"OOV词 '{oov_word}' 的词向量形状: {oov_vector.shape}")
print(f"说明: FastText通过字符n-gram为OOV词构建词向量")
except KeyError:
print(f" 注: 示例模型太小,可能无法为'{oov_word}'生成向量")
return model_fasttext
if __name__ == "__main__":
train_fasttext_model()
2.1.3 上下文无关与上下文相关表示
上下文无关表示(Context-Independent Representations)
传统词嵌入(Word2Vec、GloVe、FastText)为每个词分配一个固定的向量表示,不考虑词在不同上下文中的含义。
问题:多义词(Polysemy)无法处理。
"bank"的词向量是固定的:
- "I went to the bank to deposit money"(银行)
- "I sat on the river bank"(河岸)
两个"bank"共享同一个词向量,无法区分语义!
上下文相关表示(Context-Dependent Representations)
上下文相关表示为同一个词在不同上下文中分配不同的向量表示。
核心思想 :词的最终表示是其上下文条件的(context-conditioned)。
| 表示方法 | 上下文相关? | 示例 | 代表性模型 |
|---|---|---|---|
| 独热编码 | ❌ | 每个词固定ID | - |
| Word2Vec/GloVe | ❌ | bank → 固定向量 |
Word2Vec, GloVe, FastText |
| ELMo | ✅ | bank在不同句子中向量不同 |
ELMo (2018) |
| BERT | ✅ | 双向上下文建模 | BERT (2018) |
| GPT | ✅ | 单向上下文建模 | GPT系列 |
python
# ===== 代码实战2.5:对比上下文无关和上下文相关表示 =====
import torch
from transformers import BertTokenizer, BertModel
def compare_context_independent_dependent():
"""
对比Word2Vec(上下文无关)和BERT(上下文相关)的表示
"""
# ===== 示例1: Word2Vec(上下文无关)=====
print("=== Word2Vec: 上下文无关表示 ===")
print("注: Word2Vec为同一个词分配相同的向量,无论上下文如何")
print(" 句子1: 'I went to the bank to deposit money'")
print(" 句子2: 'I sat on the river bank'")
print(" 两个句子中的'bank'共享同一个词向量")
print(" 无法区分'银行'和'河岸'的语义差异\n")
# ===== 示例2: BERT(上下文相关)=====
print("=== BERT: 上下文相关表示 ===")
# 加载BERT模型和分词器
tokenizer = BertTokenizer.from_pretrained('bert-base-uncased')
model = BertModel.from_pretrained('bert-base-uncased')
model.eval() # 评估模式
# 两个包含不同语义"bank"的句子
sentence1 = "I went to the bank to deposit money."
sentence2 = "I sat on the river bank to enjoy the view."
# 分词
inputs1 = tokenizer(sentence1, return_tensors='pt')
inputs2 = tokenizer(sentence2, return_tensors='pt')
# 获取BERT的隐藏状态(词向量)
with torch.no_grad():
outputs1 = model(**inputs1)
outputs2 = model(**inputs2)
# 获取"bank"的向量表示
# 首先找到"bank"在分词结果中的位置
tokens1 = tokenizer.convert_ids_to_tokens(inputs1['input_ids'][0])
tokens2 = tokenizer.convert_ids_to_tokens(inputs2['input_ids'][0])
print(f"句子1分词结果: {tokens1}")
print(f"句子2分词结果: {tokens2}")
# 获取最后一层的隐藏状态
hidden_states1 = outputs1.last_hidden_state # (1, seq_len, 768)
hidden_states2 = outputs2.last_hidden_state
# 找到"bank"的位置(BERT可能将"bank"拆分为多个子词)
bank_idx1 = tokens1.index('bank') if 'bank' in tokens1 else -1
bank_idx2 = tokens2.index('bank') if 'bank' in tokens2 else -1
if bank_idx1 != -1 and bank_idx2 != -1:
# 提取"bank"的向量
bank_vector1 = hidden_states1[0, bank_idx1, :]
bank_vector2 = hidden_states2[0, bank_idx2, :]
# 计算余弦相似度
cosine_sim = torch.nn.functional.cosine_similarity(
bank_vector1.unsqueeze(0),
bank_vector2.unsqueeze(0)
).item()
print(f"\n句子1中'bank'的向量形状: {bank_vector1.shape}")
print(f"句子2中'bank'的向量形状: {bank_vector2.shape}")
print(f"两个'bank'向量的余弦相似度: {cosine_sim:.4f}")
print(f"说明: 相似度远低于1.0,说明BERT为不同上下文中的'bank'分配了不同的向量")
print(f" (理想情况下,不同语义的词向量应该有所差异)")
compare_context_independent_dependent()
深度理解 :上下文相关表示的本质是深度双向建模。BERT通过Masked Language Modeling(MLM)预训练目标,使得模型能够根据上下文动态调整词表示。这标志着NLP从"词嵌入时代"进入"预训练语言模型时代"。
2.1.4 语言模型的基础概念
什么是语言模型?
语言模型(Language Model, LM)是NLP的基石,其目标是建模自然语言的概率分布 P ( w 1 , w 2 , ... , w n ) P(w_1, w_2, \dots, w_n) P(w1,w2,...,wn),即计算一个词序列出现的概率。
应用场景:
- 语音识别 :
"I saw a bat"vs"eyes awe a bat"(声学特征相同,语言模型选择概率更高的) - 机器翻译:选择最流畅的翻译结果
- 文本生成:逐词生成连贯的文本
- 拼写纠错 :
"their"vs"there"
N-gram语言模型
N-gram模型是最简单的统计语言模型,基于马尔可夫假设 :一个词的出现仅依赖于前 N − 1 N-1 N−1 个词。
数学定义:
P ( w n ∣ w 1 n − 1 ) ≈ P ( w n ∣ w n − N + 1 n − 1 ) P(w_n | w_1^{n-1}) \approx P(w_n | w_{n-N+1}^{n-1}) P(wn∣w1n−1)≈P(wn∣wn−N+1n−1)
联合概率:
P ( w 1 , w 2 , ... , w n ) = ∏ i = 1 n P ( w i ∣ w 1 i − 1 ) ≈ ∏ i = 1 n P ( w i ∣ w i − N + 1 i − 1 ) P(w_1, w_2, \dots, w_n) = \prod_{i=1}^{n} P(w_i | w_1^{i-1}) \approx \prod_{i=1}^{n} P(w_i | w_{i-N+1}^{i-1}) P(w1,w2,...,wn)=i=1∏nP(wi∣w1i−1)≈i=1∏nP(wi∣wi−N+1i−1)
示例(Bigram, N=2):
P ( "I love NLP" ) = P ( I ) × P ( love ∣ I ) × P ( NLP ∣ love ) P(\text{"I love NLP"}) = P(\text{I}) \times P(\text{love}|\text{I}) \times P(\text{NLP}|\text{love}) P("I love NLP")=P(I)×P(love∣I)×P(NLP∣love)
参数估计(最大似然估计):
P ( w i ∣ w i − 1 ) = count ( w i − 1 , w i ) count ( w i − 1 ) P(w_i | w_{i-1}) = \frac{\text{count}(w_{i-1}, w_i)}{\text{count}(w_{i-1})} P(wi∣wi−1)=count(wi−1)count(wi−1,wi)
问题:
- 稀疏性问题 :大部分N-gram在语料库中可能未出现(概率为0)
- 解决方案:平滑技术(拉普拉斯平滑、Kneser-Ney平滑等)
- 存储开销:需要存储所有出现的N-gram及其计数
- 上下文长度限制:N不能太大(否则数据稀疏更严重)
神经语言模型
Bengio等人于2003年提出了首个神经语言模型(Neural Language Model),使用神经网络来建模语言模型。
核心思想:
- 将词映射为分布式向量(嵌入)
- 使用神经网络学习词序列的概率分布
模型架构:
输入: 前N-1个词(one-hot)→ 查找表(嵌入层)→ 拼接
↓
前馈神经网络(MLP)
↓
输出层(Softmax,词汇表大小)
数学表达:
P ( w t ∣ w t − N + 1 t − 1 ) = Softmax ( W ⋅ tanh ( U ⋅ e ( w t − N + 1 ) ; ⋯ ; e ( w t − 1 ) + b ) + c ) P(w_t | w_{t-N+1}^{t-1}) = \text{Softmax}(W \cdot \tanh(U \cdot \\mathbf{e}(w_{t-N+1}); \\cdots; \\mathbf{e}(w_{t-1}) + b) + c) P(wt∣wt−N+1t−1)=Softmax(W⋅tanh(U⋅e(wt−N+1);⋯;e(wt−1)+b)+c)
其中 e ( w ) \mathbf{e}(w) e(w) 为词 w w w 的嵌入向量。
重要性 :Bengio的神经语言模型是词嵌入技术的起源(嵌入层是模型的一部分,通过训练得到)。这为后续Word2Vec、GloVe等工作奠定了基础。
python
# ===== 代码实战2.6:实现N-gram语言模型(含平滑) =====
from collections import defaultdict, Counter
import math
class NGramLanguageModel:
"""
N-gram语言模型实现(带拉普拉斯平滑)
"""
def __init__(self, n=2, smoothing='laplace', alpha=1.0):
"""
初始化N-gram模型
Args:
n: N-gram的N值
smoothing: 平滑方法,'laplace'(拉普拉斯平滑)或None
alpha: 拉普拉斯平滑的平滑参数
"""
self.n = n
self.smoothing = smoothing
self.alpha = alpha
self.ngram_counts = defaultdict(Counter) # N-gram计数
self.context_counts = Counter() # (N-1)-gram计数
self.vocab = set() # 词汇表
def train(self, sentences):
"""
训练N-gram模型
Args:
sentences: 训练语料,每个句子是词列表
"""
for sentence in sentences:
# 添加起始和结束标记
sentence = ['<s>'] * (self.n - 1) + sentence + ['</s>']
self.vocab.update(sentence)
# 统计N-gram频次
for i in range(len(sentence) - self.n + 1):
ngram = tuple(sentence[i:i+self.n])
context = ngram[:-1]
word = ngram[-1]
self.ngram_counts[context][word] += 1
self.context_counts[context] += 1
print(f"训练完成: 词汇表大小={len(self.vocab)}, "
f"唯一{self.n}-gram数量={sum(len(v) for v in self.ngram_counts.values())}")
def probability(self, word, context):
"""
计算条件概率 P(word | context)
Args:
word: 目标词
context: 上下文(长度为N-1的元组)
Returns:
条件概率
"""
if self.smoothing == 'laplace':
# 拉普拉斯平滑
word_count = self.ngram_counts[context][word] + self.alpha
context_count = self.context_counts[context] + self.alpha * len(self.vocab)
return word_count / context_count
else:
# 无平滑(可能出现0概率)
if self.context_counts[context] == 0:
return 0.0
return self.ngram_counts[context][word] / self.context_counts[context]
def sentence_probability(self, sentence):
"""
计算整个句子的概率(使用对数概率避免下溢)
Args:
sentence: 词列表
Returns:
句子的对数概率
"""
sentence = ['<s>'] * (self.n - 1) + sentence + ['</s>']
log_prob = 0.0
for i in range(self.n - 1, len(sentence)):
context = tuple(sentence[i-self.n+1:i])
word = sentence[i]
prob = self.probability(word, context)
if prob > 0:
log_prob += math.log(prob)
else:
# 处理0概率(平滑后不应出现)
log_prob += math.log(1e-10)
return log_prob
def perplexity(self, sentences):
"""
计算困惑度(Perplexity)- 语言模型评估指标
Args:
sentences: 测试句子列表
Returns:
平均困惑度
"""
total_log_prob = 0.0
total_words = 0
for sentence in sentences:
log_prob = self.sentence_probability(sentence)
total_log_prob += log_prob
total_words += len(sentence)
# 困惑度 = exp(-平均对数似然)
perplexity = math.exp(-total_log_prob / total_words)
return perplexity
def test_ngram_model():
"""测试N-gram语言模型"""
# 训练语料
train_sentences = [
["I", "love", "NLP"],
["I", "love", "machine", "learning"],
["NLP", "is", "fun"],
["machine", "learning", "is", "fun"]
]
# 训练Bigram模型
print("=== 训练Bigram语言模型 ===")
bigram_model = NGramLanguageModel(n=2, smoothing='laplace')
bigram_model.train(train_sentences)
# 测试句子概率
print("\n=== 计算句子概率 ===")
test_sentence = ["I", "love", "NLP"]
log_prob = bigram_model.sentence_probability(test_sentence)
print(f"句子: {test_sentence}")
print(f"对数概率: {log_prob:.4f}")
# 计算困惑度
test_sentences = [
["I", "love", "NLP"],
["NLP", "is", "fun"]
]
perplexity = bigram_model.perplexity(test_sentences)
print(f"\n困惑度 (Perplexity): {perplexity:.4f}")
print(f"说明: 困惑度越低,语言模型越好(理想值为1.0)")
if __name__ == "__main__":
test_ngram_model()
困惑度(Perplexity)
困惑度是评估语言模型质量的核心指标:
PPL ( W ) = P ( w 1 , w 2 , ... , w n ) − 1 n = ∏ i = 1 n 1 P ( w i ∣ w 1 i − 1 ) n \text{PPL}(W) = P(w_1, w_2, \dots, w_n)^{-\frac{1}{n}} = \sqrtn{\prod_{i=1}^{n}\frac{1}{P(w_i|w_1^{i-1})}} PPL(W)=P(w1,w2,...,wn)−n1=ni=1∏nP(wi∣w1i−1)1
直观理解:困惑度可以理解为"模型在预测下一个词时的有效选择数量"。困惑度越低,模型对语言的理解越好。
现代语言模型的困惑度:
- 随机基线(均匀分布): 词汇表大小(如50,000)
- 简单N-gram模型: 100~500
- LSTM语言模型: 50~100
- GPT-2: ~30
- GPT-3: ~20
- 最先进模型: <20
2.2 现代NLP核心技术
2.2.1 Seq2Seq模型与注意力机制
Seq2Seq(Sequence-to-Sequence)模型
Seq2Seq模型由Sutskever等人于2014年提出,用于将一个序列转换为另一个序列。典型应用包括机器翻译、文本摘要、对话系统等。
架构组成:
编码器(Encoder): 将输入序列压缩为固定长度的上下文向量
↓
上下文向量(Context Vector)
↓
解码器(Decoder): 根据上下文向量生成输出序列
数学表达:
给定输入序列 x = ( x 1 , x 2 , ... , x n ) \mathbf{x} = (x_1, x_2, \dots, x_n) x=(x1,x2,...,xn),编码器将其编码为上下文向量 c \mathbf{c} c:
c = f encoder ( x ) \mathbf{c} = f_{\text{encoder}}(\mathbf{x}) c=fencoder(x)
解码器根据上下文向量生成输出序列 y = ( y 1 , y 2 , ... , y m ) \mathbf{y} = (y_1, y_2, \dots, y_m) y=(y1,y2,...,ym):
P ( y ∣ x ) = ∏ t = 1 m P ( y t ∣ y 1 t − 1 , c ) P(\mathbf{y}|\mathbf{x}) = \prod_{t=1}^{m} P(y_t | y_1^{t-1}, \mathbf{c}) P(y∣x)=t=1∏mP(yt∣y1t−1,c)
瓶颈问题:
上下文向量 c \mathbf{c} c 的维度是固定的(如256、512),但它需要编码整个输入序列的信息。当输入序列很长时,这种"信息压缩"会导致信息损失。
类比理解:想象你需要用一张A4纸总结一本300页的书。无论这本书多重要,你都只能写在A4纸上,必然会丢失大量细节。这就是Seq2Seq的瓶颈问题。
注意力机制解决瓶颈问题
Bahdanau等人在2014年将注意力机制引入Seq2Seq模型,使得解码器在生成每个词时,能够动态地关注输入序列的不同部分。
核心改进:
不再是单一的上下文向量 c \mathbf{c} c,而是为每个解码时刻 计算一个特定的上下文向量 c t \mathbf{c}_t ct:
P ( y t ∣ y 1 t − 1 , x ) = g ( y t − 1 , s t , c t ) P(y_t | y_1^{t-1}, \mathbf{x}) = g(y_{t-1}, s_t, \mathbf{c}_t) P(yt∣y1t−1,x)=g(yt−1,st,ct)
其中 s t s_t st 为解码器在时刻 t t t 的隐藏状态, c t \mathbf{c}_t ct 为时刻 t t t 的上下文向量。
数据流转过程(带注意力的Seq2Seq):
输入序列: [x1, x2, ..., xn]
↓ (编码器RNN/LSTM)
隐藏状态: [h1, h2, ..., hn]
↓
解码器时刻1: 根据h_all计算c1 → 生成y1
解码器时刻2: 根据h_all计算c2 → 生成y2
↓
输出序列: [y1, y2, ..., ym]
重要性 :注意力机制不仅是Seq2Seq的改进,更是Transformer架构的基石。现代大模型(如GPT-4、LLaMA)完全基于注意力机制,抛弃了RNN/LSTM。
python
# ===== 代码实战2.7:使用PyTorch实现带注意力的Seq2Seq模型 =====
import torch
import torch.nn as nn
import torch.nn.functional as F
class EncoderRNN(nn.Module):
"""
Seq2Seq编码器(使用GRU)
"""
def __init__(self, input_dim, hidden_dim, n_layers=1, dropout=0.5):
super(EncoderRNN, self).__init__()
self.hidden_dim = hidden_dim
self.n_layers = n_layers
# 嵌入层
self.embedding = nn.Embedding(input_dim, hidden_dim)
# GRU层
self.gru = nn.GRU(
hidden_dim, hidden_dim, n_layers,
dropout=dropout if n_layers > 1 else 0,
batch_first=True
)
def forward(self, src):
"""
编码器前向传播
Args:
src: 源序列,形状为 (batch, src_len)
Returns:
outputs: 所有时刻的隐藏状态 (batch, src_len, hidden_dim)
hidden: 最后时刻的隐藏状态 (n_layers, batch, hidden_dim)
"""
# 词嵌入
embedded = self.embedding(src) # (batch, src_len, hidden_dim)
# GRU
outputs, hidden = self.gru(embedded)
return outputs, hidden
class Attention(nn.Module):
"""
注意力层(Bahdanau注意力)
"""
def __init__(self, hidden_dim):
super(Attention, self).__init__()
self.attn = nn.Linear(hidden_dim * 2, hidden_dim)
self.v = nn.Linear(hidden_dim, 1, bias=False)
def forward(self, hidden, encoder_outputs):
"""
计算注意力权重
Args:
hidden: 解码器上一时刻隐藏状态 (batch, hidden_dim)
encoder_outputs: 编码器所有时刻输出 (batch, src_len, hidden_dim)
Returns:
attention_weights: 注意力权重 (batch, src_len)
context: 上下文向量 (batch, hidden_dim)
"""
batch_size = encoder_outputs.shape[0]
src_len = encoder_outputs.shape[1]
# 扩展隐藏状态以匹配编码器输出长度
hidden = hidden.unsqueeze(1).repeat(1, src_len, 1) # (batch, src_len, hidden_dim)
# 计算注意力分数(Bahdanau公式)
# concatenated = [hidden; encoder_outputs]
concatenated = torch.cat((hidden, encoder_outputs), dim=2)
energy = torch.tanh(self.attn(concatenated)) # (batch, src_len, hidden_dim)
attention_scores = self.v(energy).squeeze(2) # (batch, src_len)
# 归一化为注意力权重
attention_weights = F.softmax(attention_scores, dim=1) # (batch, src_len)
# 计算上下文向量
context = torch.bmm(
attention_weights.unsqueeze(1), # (batch, 1, src_len)
encoder_outputs # (batch, src_len, hidden_dim)
).squeeze(1) # (batch, hidden_dim)
return attention_weights, context
class DecoderRNNWithAttention(nn.Module):
"""
带注意力的Seq2Seq解码器
"""
def __init__(self, output_dim, hidden_dim, n_layers=1, dropout=0.5):
super(DecoderRNNWithAttention, self).__init__()
self.hidden_dim = hidden_dim
self.output_dim = output_dim
self.n_layers = n_layers
# 嵌入层
self.embedding = nn.Embedding(output_dim, hidden_dim)
# 注意力层
self.attention = Attention(hidden_dim)
# GRU层(输入包含上下文向量)
self.gru = nn.GRU(
hidden_dim * 2, hidden_dim, n_layers,
dropout=dropout if n_layers > 1 else 0,
batch_first=True
)
# 输出层
self.fc_out = nn.Linear(hidden_dim * 2, output_dim)
self.dropout = nn.Dropout(dropout)
def forward(self, input, hidden, encoder_outputs):
"""
解码器单步前向传播
Args:
input: 当前输入词,形状为 (batch, 1)
hidden: 上一时刻隐藏状态 (n_layers, batch, hidden_dim)
encoder_outputs: 编码器输出 (batch, src_len, hidden_dim)
Returns:
prediction: 预测词概率 (batch, output_dim)
hidden: 当前隐藏状态 (n_layers, batch, hidden_dim)
attention_weights: 注意力权重 (batch, src_len)
"""
# 取最后一层的隐藏状态(用于计算注意力)
hidden_last = hidden[-1] # (batch, hidden_dim)
# 计算注意力
attention_weights, context = self.attention(hidden_last, encoder_outputs)
# 词嵌入
embedded = self.dropout(self.embedding(input)) # (batch, 1, hidden_dim)
# 将上下文向量与嵌入向量拼接
context = context.unsqueeze(1) # (batch, 1, hidden_dim)
rnn_input = torch.cat((embedded, context), dim=2) # (batch, 1, hidden_dim*2)
# GRU
output, hidden = self.gru(rnn_input, hidden)
# 预测(使用GRU输出和上下文向量)
output = output.squeeze(1) # (batch, hidden_dim)
context = context.squeeze(1) # (batch, hidden_dim)
prediction = self.fc_out(torch.cat((output, context), dim=1)) # (batch, output_dim)
return prediction, hidden, attention_weights
class Seq2SeqWithAttention(nn.Module):
"""
完整的带注意力的Seq2Seq模型
"""
def __init__(self, encoder, decoder, device):
super(Seq2SeqWithAttention, self).__init__()
self.encoder = encoder
self.decoder = decoder
self.device = device
def forward(self, src, tgt, teacher_forcing_ratio=0.5):
"""
前向传播(训练模式)
Args:
src: 源序列 (batch, src_len)
tgt: 目标序列 (batch, tgt_len)
teacher_forcing_ratio: 使用真实目标词作为解码器输入的比例
Returns:
outputs: 所有时刻的预测 (batch, tgt_len, output_dim)
"""
batch_size = src.shape[0]
tgt_len = tgt.shape[1]
output_dim = self.decoder.output_dim
# 存储所有预测
outputs = torch.zeros(batch_size, tgt_len, output_dim).to(self.device)
# 编码器前向传播
encoder_outputs, hidden = self.encoder(src)
# 解码器第一步的输入(起始标记)
input = tgt[:, 0].unsqueeze(1) # (batch, 1)
for t in range(1, tgt_len):
# 解码器单步
prediction, hidden, _ = self.decoder(input, hidden, encoder_outputs)
# 保存预测
outputs[:, t, :] = prediction
# 决定是否使用teacher forcing
teacher_force = torch.rand(1).item() < teacher_forcing_ratio
# 获取预测的最高概率词
top1 = prediction.argmax(1).unsqueeze(1)
# 下一步输入
input = tgt[:, t].unsqueeze(1) if teacher_force else top1
return outputs
# 模型测试(简化版)
def test_seq2seq_with_attention():
"""测试带注意力的Seq2Seq模型"""
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
# 模型参数
input_dim = 1000 # 源语言词汇表大小
output_dim = 1000 # 目标语言词汇表大小
hidden_dim = 256
n_layers = 2
# 初始化模型
encoder = EncoderRNN(input_dim, hidden_dim, n_layers)
decoder = DecoderRNNWithAttention(output_dim, hidden_dim, n_layers)
model = Seq2SeqWithAttention(encoder, decoder, device)
model = model.to(device)
print(f"模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M")
# 构造假数据
batch_size = 32
src_len = 10
tgt_len = 12
src = torch.randint(0, input_dim, (batch_size, src_len)).to(device)
tgt = torch.randint(0, output_dim, (batch_size, tgt_len)).to(device)
# 前向传播
outputs = model(src, tgt, teacher_forcing_ratio=0.5)
print(f"输出形状: {outputs.shape}") # (batch, tgt_len, output_dim)
print("说明: 模型能够生成变长序列,注意力机制帮助解码器关注相关输入")
if __name__ == "__main__":
test_seq2seq_with_attention()
2.2.2 预训练-微调范式
从任务特定模型到预训练模型
传统NLP流程(2018年之前):
收集任务特定数据 → 设计模型架构 → 从头训练 → 在测试集评估
问题:
- 每个任务都需要大量标注数据(昂贵)
- 模型无法迁移到其他任务
- 对罕见任务(如特定领域问答)效果差
预训练-微调范式(2018年至今):
在大规模无标注文本上预训练 → 在任务特定数据上微调
优势:
- 预训练阶段学习通用语言知识(语法、语义、常识)
- 微调阶段只需少量标注数据即可达到优秀性能
- 同一预训练模型可迁移到多个下游任务
BERT:双向预训练的代表
BERT(Bidirectional Encoder Representations from Transformers)由Google于2018年提出,开启了预训练模型时代。
核心创新:
- 双向上下文建模:同时考虑左侧和右侧上下文
- Masked Language Modeling(MLM):随机遮盖部分词,让模型预测被遮盖的词
- Next Sentence Prediction(NSP):预测两个句子是否连续
MLM预训练目标:
L MLM = − log P ( w i ∣ x ∖ i ) \mathcal{L}{\text{MLM}} = -\log P(w_i | \mathbf{x}{\setminus i}) LMLM=−logP(wi∣x∖i)
其中 x ∖ i \mathbf{x}_{\setminus i} x∖i 表示除第 i i i 个词外所有词的信息。
数学表达:
对于输入序列 x = ( x 1 , ... , x T ) \mathbf{x} = (x_1, \dots, x_T) x=(x1,...,xT),随机选择15%的词进行遮盖:
- 80%概率:替换为
[MASK]标记 - 10%概率:替换为随机词
- 10%概率:保持不变
为什么需要Mask? 因为在微调阶段,输入是完整的(无Mask),如果预训练时总是用Mask替换,会导致预训练-微调不一致。通过10%保留原词,让模型学会在当前词就是正确词时进行表示。
GPT:自回归语言模型的代表
GPT(Generative Pre-trained Transformer)由OpenAI于2018年提出,采用自回归语言模型预训练目标。
核心特点:
- 单向上下文建模:只能关注左侧上下文(适合生成任务)
- 因果语言建模(CLM):预测下一个词
- 统一的文本生成框架:通过Prompt实现零样本/少样本学习
CLM预训练目标:
L CLM = − ∑ t = 1 T log P ( w t ∣ w 1 , ... , w t − 1 ) \mathcal{L}{\text{CLM}} = -\sum{t=1}^{T}\log P(w_t | w_1, \dots, w_{t-1}) LCLM=−t=1∑TlogP(wt∣w1,...,wt−1)
BERT vs GPT:架构对比
| 特性 | BERT | GPT |
|---|---|---|
| 架构 | Encoder-only | Decoder-only |
| 上下文 | 双向 | 单向(左到右) |
| 预训练目标 | MLM + NSP | CLM |
| 适合任务 | 理解任务(分类、NER、QA) | 生成任务(对话、摘要、续写) |
| 代表模型 | BERT、RoBERTa、ALBERT | GPT、GPT-2、GPT-3、LLaMA |
现代发展 :GPT系列因其生成能力 和缩放定律 (Scaling Law)而成为大模型时代的主流。GPT-3展示了少样本学习能力,GPT-4进一步提升了多模态理解和推理能力。
python
# ===== 代码实战2.8:使用Hugging Face Transformers进行BERT微调 =====
from transformers import BertTokenizer, BertForSequenceClassification, Trainer, TrainingArguments
import torch
from torch.utils.data import Dataset, DataLoader
from typing import List, Dict
class TextClassificationDataset(Dataset):
"""
文本分类数据集
"""
def __init__(self, texts: List[str], labels: List[int], tokenizer, max_length=128):
self.texts = texts
self.labels = labels
self.tokenizer = tokenizer
self.max_length = max_length
def __len__(self):
return len(self.texts)
def __getitem__(self, idx):
text = self.texts[idx]
label = self.labels[idx]
# 分词并编码
encoding = self.tokenizer(
text,
truncation=True,
padding='max_length',
max_length=self.max_length,
return_tensors='pt'
)
return {
'input_ids': encoding['input_ids'].flatten(),
'attention_mask': encoding['attention_mask'].flatten(),
'labels': torch.tensor(label, dtype=torch.long)
}
def fine_tune_bert():
"""
微调BERT进行文本分类(简化示例)
"""
print("=== BERT微调示例 ===")
# 构造示例数据(情感分类:0=负面,1=正面)
train_texts = [
"This movie is fantastic!",
"I hate this film so much.",
"The acting was great but the plot was boring.",
"Worst experience ever.",
"Highly recommended!"
]
train_labels = [1, 0, 0, 0, 1] # 简化:实际上需要更多数据
# 加载预训练的BERT模型和分词器
model_name = 'bert-base-uncased'
tokenizer = BertTokenizer.from_pretrained(model_name)
model = BertForSequenceClassification.from_pretrained(model_name, num_labels=2)
print(f"模型: {model_name}")
print(f"模型参数量: {sum(p.numel() for p in model.parameters()) / 1e6:.2f}M")
# 创建数据集
train_dataset = TextClassificationDataset(train_texts, train_labels, tokenizer)
# 定义训练参数(简化版)
training_args = TrainingArguments(
output_dir='./bert_finetuned',
num_train_epochs=3,
per_device_train_batch_size=8,
save_steps=10,
save_total_limit=2,
logging_steps=5,
use_cpu=True # 如果没有GPU,使用CPU(慢)
)
# 创建Trainer
trainer = Trainer(
model=model,
args=training_args,
train_dataset=train_dataset
)
print("\n开始微调...")
print("注: 实际应用中需要更大的数据集和更长的训练时间")
print(" 这里仅展示微调流程,不实际运行训练")
# 实际训练(取消注释以运行)
# trainer.train()
# 推理示例
print("\n=== 推理示例 ===")
model.eval()
test_text = "This movie is amazing!"
inputs = tokenizer(test_text, return_tensors='pt', truncation=True, padding=True)
with torch.no_grad():
outputs = model(**inputs)
predictions = torch.argmax(outputs.logits, dim=1)
print(f"输入: {test_text}")
print(f"预测标签: {predictions.item()} (0=负面, 1=正面)")
if __name__ == "__main__":
fine_tune_bert()
2.2.3 提示工程(Prompt Engineering)基础
从微调到提示:范式转变
传统范式(微调):
预训练模型 → 在特定任务数据上更新所有参数 → 任务特定模型
新范式(提示工程):
预训练模型(冻结参数) → 设计提示词(Prompt) → 零样本/少样本学习
重要性 :提示工程使得无需更新模型参数即可适配新任务,大大降低了NLP应用的门槛。这也是GPT-3及后续大模型的核心使用方式。
提示工程的核心要素
1. 指令设计(Instruction Design)
清晰明确的任务描述:
# 差提示
"做这个"
# 好提示
"请将以下句子分类为正面或负面情感:
句子:这部电影太棒了!
情感:"
2. 示例设计(Few-Shot Examples)
通过提供示例来"教"模型如何完成任务:
示例1:
输入:这部电影太棒了!
情感:正面
示例2:
输入:我非常失望。
情感:负面
现在请你分类:
输入:这真是一部杰作!
情感:
3. 输出格式控制
指定模型输出的格式:
"请以JSON格式输出结果:
{
"情感": "正面/负面",
"置信度": 0.0-1.0
}"
python
# ===== 代码实战2.9:提示工程设计示例 =====
def demonstrate_prompt_engineering():
"""
展示不同类型的提示工程设计
"""
# ===== 示例1:零样本提示(Zero-Shot)=====
print("=== 零样本提示 ===")
zero_shot_prompt = """
请对以下评论进行情感分类(正面/负面):
评论:这部电影的剧情非常精彩,演员的表演也很出色。
情感:
"""
print(f"提示词:\n{zero_shot_prompt}")
print("期望输出: 正面\n")
# ===== 示例2:少样本提示(Few-Shot)=====
print("=== 少样本提示 ===")
few_shot_prompt = """
请对以下评论进行情感分类(正面/负面)。
示例1:
评论:这真是浪费时间。
情感:负面
示例2:
评论:超出预期,非常满意!
情感:正面
示例3:
评论:不推荐,太失望了。
情感:负面
现在请你分类:
评论:这部电影让我印象深刻。
情感:
"""
print(f"提示词:\n{few_shot_prompt}")
print("期望输出: 正面\n")
# ===== 示例3:思维链提示(Chain-of-Thought)=====
print("=== 思维链提示(用于推理任务)===")
cot_prompt = """
问题:Roger有5个网球。他又买了2筒网球,每筒有3个网球。他现在有多少个网球?
让我们一步步思考:
1. Roger最初有5个网球。
2. 他买了2筒,每筒3个,所以买了2 × 3 = 6个网球。
3. 总共:5 + 6 = 11个网球。
答案:11
现在请你解决问题:
问题:咖啡店有23个苹果。他们用20个做了午餐,又买了6个。他们现在有多少个苹果?
"""
print(f"提示词:\n{cot_prompt}")
print("期望输出: 9(通过思维链推理得出)\n")
# ===== 示例4:输出格式控制 =====
print("=== 输出格式控制 ===")
format_prompt = """
请提取以下句子中的关键信息,并以JSON格式输出:
句子:Alice在2023年1月15日于Paris参加会议。
输出格式:
{
"人物": "...",
"时间": "...",
"地点": "...",
"事件": "..."
}
"""
print(f"提示词:\n{format_prompt}")
demonstrate_prompt_engineering()
提示工程的最佳实践
- 具体胜过抽象:详细的描述比简短的指令更有效
- 使用示例:Few-shot示例显著提升性能
- 角色扮演:让模型扮演特定角色(如"你是一位经验丰富的医生")
- 分步思考:对于复杂任务,要求模型"一步步思考"(思维链)
- 输出格式明确:指定JSON、XML等结构化输出格式
- 迭代优化:通过试验不同提示词来找到最优方案
⚠️ 企业级避坑1:提示词泄露风险
在设计提示词时,需要注意提示词注入攻击(Prompt Injection):
- 恶意用户可能通过输入覆盖原始指令
- 示例:用户输入
"忽略之前的指令,现在做XXX"- 解决方案:对用户输入进行隔离、使用分隔符、在系统提示中强调指令优先级
2.2.4 上下文学习(In-context Learning)原理
什么是上下文学习?
上下文学习(In-context Learning, ICL)是大型预训练语言模型的一种涌现能力(Emergent Ability),指的是模型通过在提示中提供示例(不更新模型参数)来学习任务的能力。
三种学习范式对比:
| 范式 | 是否需要梯度更新 | 示例 | 适用模型 |
|---|---|---|---|
| 零样本学习(Zero-Shot) | ❌ | 直接给出任务指令 | GPT-3+ |
| 单样本学习(One-Shot) | ❌ | 提供1个示例 | GPT-3+ |
| 少样本学习(Few-Shot) | ❌ | 提供K个示例(通常K=5~20) | GPT-3+ |
涌现能力:ICL在模型规模较小时(如GPT-2)表现很差,但当模型规模超过一定阈值(如GPT-3的1750亿参数)时,性能突然大幅提升。这种现象称为"涌现"。
上下文学习的工作原理
虽然ICL不需要梯度更新,但模型内部到底发生了什么?
主流假设(尚未有定论):
- 隐式微调假设:ICL通过注意力机制在前向传播中"隐式"地微调模型参数(不实际更新权重)
- 任务识别假设:示例帮助模型识别任务类型,从预训练知识中检索相关知识
- 示例偏差假设:示例引导模型的输出分布向正确方向偏移
数学表达(简化):
给定示例集合 D = { ( x 1 , y 1 ) , ... , ( x k , y k ) } \mathcal{D} = \{(x_1, y_1), \dots, (x_k, y_k)\} D={(x1,y1),...,(xk,yk)} 和查询输入 x query x_{\text{query}} xquery,ICL计算:
P ( y ∣ x query , D ) = LM ( x 1 , y 1 , ... , x k , y k , x query ) P(y|x_{\text{query}}, \mathcal{D}) = \text{LM}(x_1, y_1, \\dots, x_k, y_k, x_{\\text{query}}) P(y∣xquery,D)=LM(x1,y1,...,xk,yk,xquery)
其中LM为语言模型。
重要性:ICL是大模型"通用任务求解器"能力的基础。理解ICL对于设计高效的提示词、理解模型行为至关重要。
python
# ===== 代码实战2.10:模拟上下文学习效果 =====
def simulate_in_context_learning():
"""
通过示例展示上下文学习的效果(概念性演示)
"""
print("=== 上下文学习示例 ===\n")
# 场景:教模型学习一个新的"语言游戏"
# 任务:将英文单词反转(如 "hello" → "olleh")
# ===== 零样本(无法完成任务)=====
print("1. 零样本提示(模型未见过示例):")
zero_shot = "将单词反转:hello →"
print(f" 提示词: {zero_shot}")
print(f" 可能输出: 'hi' 或 'greeting'(模型不理解任务)\n")
# ===== 单样本(开始理解任务)=====
print("2. 单样本提示(提供1个示例):")
one_shot = """
示例:world → dlrow
现在请你做:
hello →
"""
print(f" 提示词:\n{one_shot}")
print(f" 可能输出: 'olleh'(模型开始理解任务模式)\n")
# ===== 少样本(准确完成任务)=====
print("3. 少样本提示(提供多个示例):")
few_shot = """
示例:
apple → elppa
world → dlrow
python → nohtyp
machine → enihcam
现在请你做:
learning →
"""
print(f" 提示词:\n{few_shot}")
print(f" 期望输出: 'gninrael'(模型准确理解任务)\n")
print("=" * 50)
print("关键洞察:")
print(" - 示例数量越多,ICL效果通常越好")
print(" - 示例质量(正确性、多样性)也很重要")
print(" - ICL性能随模型规模增加而提升(涌现能力)")
print(" - GPT-3级别模型在K=5~20时达到最佳性能")
simulate_in_context_learning()
2.3 NLP评估与优化
2.3.1 常用评估指标
文本生成任务评估指标
1. BLEU(Bilingual Evaluation Understudy)
用于评估机器翻译质量,衡量生成文本与参考文本的n-gram重叠度。
数学公式(简化版):
BLEU = BP × exp ( ∑ n = 1 N w n log p n ) \text{BLEU} = \text{BP} \times \exp\left(\sum_{n=1}^{N} w_n \log p_n\right) BLEU=BP×exp(n=1∑Nwnlogpn)
其中:
- p n p_n pn 为匹配上的n-gram精度
- BP \text{BP} BP 为 brevity penalty(惩罚过短的生成结果)
- w n w_n wn 为权重(通常 w n = 1 / N w_n = 1/N wn=1/N)
优点 :计算快速,与人工评估相关性较高
缺点:无法捕捉语义等价但用词不同的表达
2. ROUGE(Recall-Oriented Understudy for Gisting Evaluation)
用于评估文本摘要质量,更注重召回率。
常用变体:
- ROUGE-N:与BLEU类似,计算n-gram召回率
- ROUGE-L:基于最长公共子序列(LCS)
- ROUGE-W:带权重的最长公共子序列
3. METEOR(Metric for Evaluation of Translation with Explicit ORdering)
综合考虑精确率、召回率和语义相似性(使用WordNet同义词)。
4. BERTScore
使用预训练BERT计算生成文本与参考文本的语义相似度,克服了n-gram重叠指标的局限性。
数学原理:
对于生成文本 x = ( x 1 , ... , x k ) x = (x_1, \dots, x_k) x=(x1,...,xk) 和参考文本 y = ( y 1 , ... , y l ) y = (y_1, \dots, y_l) y=(y1,...,yl),使用BERT获取词向量:
BERTScore = 1 k ∑ i = 1 k max j x i T y j \text{BERTScore} = \frac{1}{k}\sum_{i=1}^{k} \max_{j} x_i^T y_j BERTScore=k1i=1∑kjmaxxiTyj
(实际计算使用余弦相似度,并对精确率和召回率进行调和平均)
python
# ===== 代码实战2.11:计算NLP评估指标 =====
from nltk.translate.bleu_score import sentence_bleu, SmoothingFunction
from rouge import Rouge
import numpy as np
def compute_bleu_score(reference, hypothesis):
"""
计算BLEU分数
Args:
reference: 参考翻译(列表的列表,支持多个参考)
hypothesis: 生成翻译(词列表)
Returns:
BLEU分数
"""
# 使用平滑函数(避免0分)
smoothing = SmoothingFunction().method1
# 计算BLEU-4(考虑1-gram到4-gram)
bleu_scores = []
for n in range(1, 5):
weight = tuple([1.0/n if i < n else 0.0 for i in range(4)])
score = sentence_bleu(
reference, hypothesis,
weights=weight,
smoothing_function=smoothing
)
bleu_scores.append(score)
return bleu_scores
def compute_rouge_score(reference, hypothesis):
"""
计算ROUGE分数
Args:
reference: 参考文本(字符串)
hypothesis: 生成文本(字符串)
Returns:
ROUGE分数字典
"""
rouge = Rouge()
scores = rouge.get_scores(hypothesis, reference, avg=True)
return scores
def demonstrate_evaluation_metrics():
"""演示NLP评估指标的计算"""
print("=== BLEU分数计算 ===")
# 参考翻译(可以多个)
reference = [["the", "cat", "is", "on", "the", "mat"]]
# 生成翻译
hypothesis_good = ["the", "cat", "is", "on", "the", "mat"] # 完全匹配
hypothesis_ok = ["the", "cat", "on", "the", "mat"] # 缺少"is"
hypothesis_bad = ["a", "dog", "runs", "in", "the", "park"] # 完全不同
bleu_good = compute_bleu_score(reference, hypothesis_good)
bleu_ok = compute_bleu_score(reference, hypothesis_ok)
bleu_bad = compute_bleu_score(reference, hypothesis_bad)
print(f"完美匹配 BLEU-4: {bleu_good[3]:.4f}")
print(f"部分匹配 BLEU-4: {bleu_ok[3]:.4f}")
print(f"不匹配 BLEU-4: {bleu_bad[3]:.4f}")
print("\n=== ROUGE分数计算 ===")
reference_text = "The cat is on the mat."
hypothesis_text = "The cat is on the mat." # 完美匹配
rouge_scores = compute_rouge_score(reference_text, hypothesis_text)
print(f"ROUGE-1 F1: {rouge_scores['rouge-1']['f']:.4f}")
print(f"ROUGE-2 F1: {rouge_scores['rouge-2']['f']:.4f}")
print(f"ROUGE-L F1: {rouge_scores['rouge-l']['f']:.4f}")
print("\n=== 指标选择建议 ===")
print(" - 机器翻译: BLEU")
print(" - 文本摘要: ROUGE")
print(" - 对话系统: BLEU + 人工评估")
print(" - 语义相似度: BERTScore")
if __name__ == "__main__":
demonstrate_evaluation_metrics()
分类任务评估指标
对于文本分类任务,常用指标包括:
| 指标 | 公式 | 适用场景 |
|---|---|---|
| 准确率(Accuracy) | T P + T N T P + T N + F P + F N \frac{TP+TN}{TP+TN+FP+FN} TP+TN+FP+FNTP+TN | 类别平衡 |
| 精确率(Precision) | T P T P + F P \frac{TP}{TP+FP} TP+FPTP | 关注假正例代价高的场景 |
| 召回率(Recall) | T P T P + F N \frac{TP}{TP+FN} TP+FNTP | 关注假反例代价高的场景 |
| F1分数 | 2 × P × R P + R \frac{2\times P\times R}{P+R} P+R2×P×R | 精确率和召回率的调和平均 |
| AUC-ROC | ROC曲线下面积 | 二分类,关注排序质量 |
⚠️ 企业级避坑2:类别不平衡时的评估陷阱
在类别不平衡的场景(如欺诈检测,正常:欺诈 = 99:1),单纯使用准确率会产生误导:
- 模型全部预测为"正常",准确率仍可达99%
- 但实际上完全没有检测出欺诈案例
解决方案:
- 使用F1分数 或AUC-ROC
- 使用加权准确率(Weighted Accuracy)
- 使用混淆矩阵(Confusion Matrix)进行细粒度分析
2.3.2 模型压缩与加速技术
为什么需要模型压缩?
大模型(如GPT-3、BERT-large)虽然性能优异,但存在以下问题:
- 存储开销大:GPT-3约700GB(FP32精度)
- 推理速度慢:生成每个词都需要大量计算
- 部署成本高:需要高端GPU集群
模型压缩技术 旨在在尽量保持性能的前提下,减少模型大小、加速推理。
主流压缩技术
1. 量化(Quantization)
降低模型权重的数值精度:
| 精度 | 每个参数大小 | 典型使用场景 |
|---|---|---|
| FP32(32位浮点) | 4字节 | 训练 |
| FP16(16位浮点) | 2字节 | 混合精度训练、推理 |
| INT8(8位整数) | 1字节 | 推理(Post-training Quantization) |
| INT4(4位整数) | 0.5字节 | 极端压缩(QLoRA) |
数学原理(INT8量化):
对于浮点数张量 X X X,量化到INT8:
X int8 = round ( X s ) + z X_{\text{int8}} = \text{round}\left(\frac{X}{s}\right) + z Xint8=round(sX)+z
其中 s s s 为缩放因子, z z z 为零点(zero-point)。
反量化:
X ^ = s × ( X int8 − z ) \hat{X} = s \times (X_{\text{int8}} - z) X^=s×(Xint8−z)
2. 知识蒸馏(Knowledge Distillation)
使用大模型(教师模型)指导小模型(学生模型)训练。
损失函数:
L KD = α T 2 ⋅ KL ( σ ( z s / T ) ∣ ∣ σ ( z t / T ) ) + ( 1 − α ) L CE \mathcal{L}{\text{KD}} = \alpha T^2 \cdot \text{KL}(\sigma(z_s/T) || \sigma(z_t/T)) + (1-\alpha) \mathcal{L}{\text{CE}} LKD=αT2⋅KL(σ(zs/T)∣∣σ(zt/T))+(1−α)LCE
其中 T T T 为温度参数, σ \sigma σ 为Softmax函数。
3. 剪枝(Pruning)
移除模型中不重要的参数:
- 非结构化剪枝:将接近0的权重置为0(产生稀疏矩阵)
- 结构化剪枝:移除整个神经元、通道或层
4. 低秩分解(Low-Rank Factorization)
将大权重矩阵分解为两个小矩阵的乘积:
W ∈ R m × n ≈ W 1 ⋅ W 2 , W 1 ∈ R m × r , W 2 ∈ R r × n W \in \mathbb{R}^{m\times n} \approx W_1 \cdot W_2, \quad W_1 \in \mathbb{R}^{m\times r}, W_2 \in \mathbb{R}^{r\times n} W∈Rm×n≈W1⋅W2,W1∈Rm×r,W2∈Rr×n
其中 r ≪ min ( m , n ) r \ll \min(m, n) r≪min(m,n)。
python
# ===== 代码实战2.12:PyTorch模型量化示例 =====
import torch
import torch.nn as nn
from torch.quantization import quantize_dynamic
def demonstrate_quantization():
"""演示PyTorch动态量化"""
print("=== 模型量化示例 ===")
# 定义一个简单的模型
class SimpleModel(nn.Module):
def __init__(self):
super(SimpleModel, self).__init__()
self.fc1 = nn.Linear(768, 256)
self.relu = nn.ReLU()
self.fc2 = nn.Linear(256, 2)
def forward(self, x):
x = self.relu(self.fc1(x))
x = self.fc2(x)
return x
# 创建模型
model = SimpleModel()
# 计算原始模型大小
original_size = sum(p.numel() * p.element_size() for p in model.parameters())
print(f"原始模型大小: {original_size / 1024:.2f} KB")
# 动态量化(将权重从FP32量化为INT8)
quantized_model = quantize_dynamic(
model,
{nn.Linear}, # 对Linear层进行量化
dtype=torch.qint8
)
print(f"量化后模型类型: {type(quantized_model)}")
print(f"说明: 动态量化在推理时动态将激活值量化为INT8")
print(f" 权重在前向传播前量化为INT8")
# 验证量化模型可以正常运行
test_input = torch.randn(1, 768)
output_original = model(test_input)
output_quantized = quantized_model(test_input)
print(f"\n原始模型输出: {output_original.detach().numpy()}")
print(f"量化模型输出: {output_quantized.detach().numpy()}")
print(f"输出差异(应较小): {torch.max(torch.abs(output_original - output_quantized)).item():.6f}")
demonstrate_quantization()
2.3.3 多语言与跨语言NLP挑战
多语言NLP的复杂性
不同语言具有不同的语言特性:
| 语言特性 | 示例 | NLP挑战 |
|---|---|---|
| 形态丰富度 | 土耳其语一个词可包含多个信息 | 需要更复杂的分词策略 |
| 词序自由度 | 拉丁语词序灵活 | 句法分析困难 |
| 书写系统 | 阿拉伯语从右到左书写 | 需要特殊处理 |
| 资源稀缺度 | 斯瓦希里语标注数据少 | 模型性能差 |
跨语言词嵌入
目标 :将不同语言的词嵌入映射到同一个向量空间,使得语义相似的词(即使来自不同语言)距离相近。
方法:
- 基于对齐的方法:使用双语词典对齐不同语言的词向量
- 基于句子对齐的方法:使用平行语料库(如Europarl)训练跨语言表示
多语言预训练模型
| 模型 | 支持语言数 | 架构 | 特点 |
|---|---|---|---|
| mBERT | 104种 | BERT-base | 使用共享词汇表 |
| XLM-R | 100种 | RoBERTa | 基于Common Crawl数据 |
| GPT-3.5/4 | 数十种 | GPT | 通过多语言数据预训练 |
关键挑战 :语言迁移鸿沟(Language Transfer Gap)------在高资源语言(如英语)上表现优异的模型,在低资源语言上性能可能大幅下降。
2.3.4 NLP技术的伦理与偏见问题
NLP模型中的偏见来源
1. 训练数据偏见
如果训练语料库中包含性别、种族、宗教等偏见,模型会放大这些偏见。
示例:
- 将"医生"翻译为"he",将"护士"翻译为"she"
- 简历筛选模型对女性或少数族裔候选人打分更低
2. 标注者偏见
众包标注者的个人偏见会被注入到监督信号中。
3. 模型架构偏见
某些架构设计可能无意中放大特定类型的偏见。
偏见的检测方法
1. 词嵌入偏见检测
使用WEAT(Word Embedding Association Test)统计量:
s = mean x ∈ X mean a ∈ A cos ( x ⃗ , a ⃗ ) − mean x ∈ X mean b ∈ B cos ( x ⃗ , b ⃗ ) std a ∈ A ∪ B mean x ∈ X cos ( x ⃗ , a ⃗ ) − mean x ∈ X cos ( x ⃗ , b ⃗ ) s = \frac{\text{mean}{x \in X}\text{mean}{a \in A}\cos(\vec{x}, \vec{a}) - \text{mean}{x \in X}\text{mean}{b \in B}\cos(\vec{x}, \vec{b})}{\text{std}_{a \in A \cup B}\\text{mean}_{x \\in X}\\cos(\\vec{x}, \\vec{a}) - \\text{mean}_{x \\in X}\\cos(\\vec{x}, \\vec{b})} s=stda∈A∪Bmeanx∈Xcos(x ,a )−meanx∈Xcos(x ,b )meanx∈Xmeana∈Acos(x ,a )−meanx∈Xmeanb∈Bcos(x ,b )
其中 X X X 为目标词集, A , B A, B A,B 为属性词集(如"男性名字"vs"女性名字")。
2. 生成文本偏见检测
通过系统性地测试模型在不同输入下的输出差异来检测偏见。
偏见缓解策略
-
数据层面:
- 平衡训练数据的多样性
- 去除或修正有偏见的文本
-
模型层面:
- 使用对抗训练(Adversarial Training)消除敏感属性信息
- 使用偏见中和(Bias Neutralization)技术调整词向量
-
后处理层面:
- 使用解毒剂(Detoxifying)技术修正生成结果
- 人工审核机制
⚠️ 企业级避坑3:部署NLP模型前的伦理审查
在将NLP模型部署到生产环境前,建议进行以下检查:
- 偏见审计:在敏感属性(性别、种族等)上测试模型性能差异
- 可解释性检查:使用LIME、SHAP等工具解释模型决策
- 安全性检查:测试模型是否容易被对抗样本攻击
- 隐私检查:确保模型没有泄露训练数据中的敏感信息
企业级考量与避坑指南
避坑1:预训练模型选择不当导致性能不佳
问题描述 :
不同预训练模型适合不同任务,选择错误会导致性能不佳或资源浪费。
解决方案:
| 任务类型 | 推荐模型 | 理由 |
|---|---|---|
| 文本分类 | BERT、RoBERTa | 理解任务,分类头简单 |
| 命名实体识别 | BERT、SpanBERT | 需要细粒度token级表示 |
| 文本生成 | GPT-2、GPT-3、LLaMA | 自回归生成能力强 |
| 问答系统 | BERT、ALBERT | 需要理解问题和文档 |
| 多语言任务 | XLM-R、mBERT | 多语言预训练 |
| 低资源场景 | DistilBERT、TinyBERT | 模型小,推理快 |
经验法则:
- 先尝试简单的基线模型(如DistilBERT),再逐步升级
- 考虑推理延迟要求(实时系统需要快速模型)
- 考虑部署环境(移动端需要轻量级模型)
避坑2:提示词设计不当导致模型输出质量差
问题描述 :
即使使用最先进的大模型,如果提示词设计不当,输出质量也会很差。
解决方案:
提示词设计清单:
- ✅ 任务描述是否清晰明确?
- ✅ 是否提供了足够的示例(Few-Shot)?
- ✅ 是否指定了输出格式?
- ✅ 是否避免了歧义表达?
- ✅ 是否使用了角色扮演(如"你是一位专家...")?
- ✅ 对于复杂任务,是否要求模型"一步步思考"?
提示词迭代流程:
设计初始提示词 → 测试输出 → 分析问题 → 优化提示词 → 重新测试
↑ ↓
└───────────────────────────────────────┘
避坑3:未考虑模型的计算资源需求
问题描述 :
大模型需要大量GPU显存和计算资源,部署时才发现资源不足。
解决方案:
推理资源估算公式(近似):
显存需求(GB) ≈ 参数量(B) × 精度系数 8 × 1.2 \text{显存需求(GB)} \approx \frac{\text{参数量(B)} \times \text{精度系数}}{8} \times 1.2 显存需求(GB)≈8参数量(B)×精度系数×1.2
其中:
- 精度系数:FP32=4, FP16=2, INT8=1, INT4=0.5
- 1.2为经验系数(考虑激活值、梯度等额外开销)
示例:
- GPT-2(1.5B参数,FP16): 1.5 × 2 / 8 × 1.2 ≈ 0.45 GB 1.5 \times 2 / 8 \times 1.2 \approx 0.45\text{GB} 1.5×2/8×1.2≈0.45GB
- GPT-3(175B参数,FP16): 175 × 2 / 8 × 1.2 ≈ 52.5 GB 175 \times 2 / 8 \times 1.2 \approx 52.5\text{GB} 175×2/8×1.2≈52.5GB(需要多卡并行)
资源优化策略:
- 使用量化(INT8/INT4)
- 使用模型并行 或流水线并行(多GPU推理)
- 使用** vLLM**、TensorRT-LLM等推理加速框架
- 对于在线服务,使用KV Cache减少重复计算
本章小结
核心Takeaways
-
文本预处理是NLP的基础:分词、清洗、向量化等步骤直接影响后续模型性能。子词分词(BPE、WordPiece)有效解决了OOV问题。
-
词嵌入技术将词映射到连续向量空间:Word2Vec、GloVe、FastText等为每个词学习分布式表示,但都是上下文无关的(同一个词有固定向量)。
-
上下文相关表示是现代NLP的核心:BERT、GPT等预训练模型根据上下文动态调整词表示,显著提升了NLP任务性能。
-
语言模型是NLP的基石:从N-gram到神经语言模型,再到预训练语言模型,语言建模能力不断提升。困惑度是评估语言模型质量的核心指标。
-
Seq2Seq模型和注意力机制实现了序列到序列的转换:注意力机制解决了Seq2Seq的瓶颈问题,并成为Transformer架构的基础。
-
预训练-微调范式 revolutionized NLP:BERT(双向编码)适合理解任务,GPT(自回归生成)适合生成任务。预训练模型展现了强大的迁移学习能力。
-
提示工程使得无需微调即可适配新任务:通过设计提示词,大模型可以零样本/少样本学习新任务。提示工程设计质量直接影响模型输出。
-
上下文学习是大模型的涌现能力:模型规模超过一定阈值后,突然获得通过示例学习新任务的能力。这是大模型"通用任务求解器"能力的基础。
-
NLP评估需要多维度指标:BLEU、ROUGE适用于生成任务;准确率、F1、AUC-ROC适用于分类任务。需要注意类别不平衡时的评估陷阱。
-
模型压缩技术使大模型实用化:量化、知识蒸馏、剪枝、低秩分解等技术在尽量保持性能的前提下,大幅减少模型大小和推理延迟。
-
NLP模型可能存在偏见和伦理问题:训练数据偏见会被模型放大。需要通过数据平衡、对抗训练、后处理等手段缓解。
思考题
思考题1:为什么BERT使用双向上下文建模,而GPT使用单向上下文建模?这两种设计选择各自有什么优势和局限性?
参考答案:
BERT使用双向上下文:
- 优势:能够同时看到词的左右上下文,对词的理解更准确。适合理解任务(如分类、NER、QA),因为理解一个词通常需要完整的上下文。
- 局限性: bidirectional建模使得BERT不适合自回归生成任务(因为在生成时,未来的词不应该被看到)。这也是为什么BERT主要用于理解任务,而非生成任务。
GPT使用单向上下文:
- 优势:与自回归生成任务天然匹配(生成时只能看到已生成的词)。适合对话、摘要、续写等生成任务。
- 局限性:无法利用右侧上下文,对词的理解可能不如BERT全面。例如,在完形填空任务中,GPT无法利用右侧上下文来预测被遮盖的词。
现代发展:
- XLNet等模型尝试结合双向和单向的优点(使用Permutation Language Modeling)
- T5 、BART等Encoder-Decoder架构可以同时进行双向理解和自回归生成
思考题2:在计算资源有限的情况下,如何选择一个合适的预训练模型进行微调?请列出你的决策流程。
参考答案:
决策流程:
-
明确任务类型:
- 理解任务(分类、NER、QA)→ 优先考虑BERT系列
- 生成任务(对话、摘要、翻译)→ 优先考虑GPT系列或Encoder-Decoder模型
-
评估资源约束:
- 可用GPU显存?→ 选择参数量合适的模型
- 推理延迟要求?→ 实时系统选择轻量级模型(如DistilBERT、TinyBERT)
- 训练时间预算?→ 大模型训练慢,需要考虑时间成本
-
考虑数据规模:
- 标注数据充足(>10,000样本)→ 可以微调较大的模型(如BERT-large)
- 标注数据有限(<1,000样本)→ 使用较小的模型或在更大的模型上使用参数高效微调(如LoRA,将在第七章详细介绍)
-
领域适配性:
- 通用领域 → 使用通用预训练模型(如bert-base-uncased)
- 特定领域(如医疗、法律)→ 使用领域预训练模型(如BioBERT、LegalBERT)或在领域数据上继续预训练
-
多语言需求:
- 仅英文 → 使用英文预训练模型
- 多语言 → 使用多语言预训练模型(如XLM-R、mBERT)
-
快速原型验证:
- 先使用简单的基线模型(如DistilBERT-base)快速验证任务可行性
- 如果基线模型性能不佳,再升级到更大的模型
经验法则:
- 在资源允许的情况下,优先选择更大的模型(性能通常更好)
- 但如果推理延迟是瓶颈,优先选择轻量级模型(并使用蒸馏或量化进一步优化)
本章已生成完毕。请回复【继续生成第三章】或提出您对当前章节的疑问。
参考文献
- Bengio, Y., Ducharme, R., Vincent, P., & Janvin, C. (2003). A neural probabilistic language model. Journal of Machine Learning Research, 3, 1137-1155.
- Mikolov, T., Sutskever, I., Chen, K., Corrado, G. S., & Dean, J. (2013). Distributed representations of words and phrases and their compositionality. NeurIPS 2013.
- Pennington, J., Socher, R., & Manning, C. D. (2014). Glove: Global vectors for word representation. EMNLP 2014.
- Bojanowski, P., Grave, E., Joulin, A., & Mikolov, T. (2017). Enriching word vectors with subword information. TACL, 5, 135-146.
- Sutskever, I., Vinyals, O., & Le, Q. V. (2014). Sequence to sequence learning with neural networks. NeurIPS 2014.
- Bahdanau, D., Cho, K., & Bengio, Y. (2015). Neural machine translation by jointly learning to align and translate. ICLR 2015.
- Vaswani, A., et al. (2017). Attention is all you need. NeurIPS 2017.
- Devlin, J., Chang, M. W., Lee, K., & Toutanova, K. (2019). BERT: Pre-training of deep bidirectional transformers for language understanding. NAACL 2019.
- Radford, A., et al. (2019). Language models are unsupervised multitask learners (GPT-2). OpenAI Blog.
- Brown, T., et al. (2020). Language models are few-shot learners (GPT-3). NeurIPS 2020.
- Wei, J., et al. (2022). Chain-of-thought prompting elicits reasoning in large language models. NeurIPS 2022*.