Skip to content

第 4 章 · 文本分类

本章目标:用表示模型(任务专用模型、嵌入 + 分类器、零样本)与生成模型(encoder-decoder 与 ChatGPT)两条技术路线完成影评情感分类,并学会评估各自的性能。

本章对应《Hands-On Large Language Models》(Jay Alammar & Maarten Grootendorst 著)第 4 章的官方笔记本:分别用 representative models 和 generative models 对文本做分类。

4.1 [可选] 在 Colab 上安装依赖

如果你在 Google Colab(或任何其他云环境)中查看本笔记本,需要取消注释并运行下面的代码块来安装本章的依赖:

💡 NOTE:运行本章示例需要 GPU。在 Google Colab 中,进入 Runtime > Change runtime type > Hardware accelerator > GPU > GPU type > T4

python
# %%capture
# !pip install "transformers==4.41.2" "sentence-transformers==3.0.1" openai
# !pip install -U datasets

4.2 数据

我们使用 Rotten Tomatoes 影评数据集——每条评论标注为正面或负面:

python
from datasets import load_dataset

# Load our data
data = load_dataset("rotten_tomatoes")
data
python
data["train"][0, -1]

查看第一条与最后一条训练样本,感受一下数据的形态:一条 text(评论文本)加一个 label(0 = 负面,1 = 正面)。

4.3 使用表示模型做文本分类

4.3.1 使用任务专用模型

最直接的做法是使用一个已经在类似任务上微调过的模型。这里选择 cardiffnlp/twitter-roberta-base-sentiment-latest,一个在推特数据上训练的情感分析模型:

python
from transformers import pipeline

# Path to our HF model
model_path = "cardiffnlp/twitter-roberta-base-sentiment-latest"

# Load model into pipeline
pipe = pipeline(
    model=model_path,
    tokenizer=model_path,
    return_all_scores=True,
    device="cuda:0"
)
python
import numpy as np
from tqdm import tqdm
from transformers.pipelines.pt_utils import KeyDataset

# Run inference
y_pred = []
for output in tqdm(pipe(KeyDataset(data["test"], "text")), total=len(data["test"])):
    negative_score = output[0]["score"]
    positive_score = output[2]["score"]
    assignment = np.argmax([negative_score, positive_score])
    y_pred.append(assignment)

该模型输出三类(negative / neutral / positive),我们的任务是二分类,所以只比较负面与正面两个分数,取较大者作为预测结果。

接下来定义评估函数并查看整体性能:

python
from sklearn.metrics import classification_report

def evaluate_performance(y_true, y_pred):
    """Create and print the classification report"""
    performance = classification_report(
        y_true, y_pred,
        target_names=["Negative Review", "Positive Review"]
    )
    print(performance)
python
evaluate_performance(data["test"]["label"], y_pred)

4.3.2 利用嵌入做分类任务

监督分类

不使用现成的情感模型,而是先用嵌入模型把文本转成向量,再在向量上训练一个普通分类器:

python
from sentence_transformers import SentenceTransformer

# Load model
model = SentenceTransformer('sentence-transformers/all-mpnet-base-v2')

# Convert text to embeddings
train_embeddings = model.encode(data["train"]["text"], show_progress_bar=True)
test_embeddings = model.encode(data["test"]["text"], show_progress_bar=True)
python
train_embeddings.shape

all-mpnet-base-v2 输出 768 维嵌入向量。

python
from sklearn.linear_model import LogisticRegression

# Train a Logistic Regression on our train embeddings
clf = LogisticRegression(random_state=42)
clf.fit(train_embeddings, data["train"]["label"])
python
# Predict previously unseen instances
y_pred = clf.predict(test_embeddings)
evaluate_performance(data["test"]["label"], y_pred)

Tip!

如果完全不使用分类器会怎样?我们可以对每个类别的嵌入求平均,再用余弦相似度预测哪一类与文档最匹配:

python
import numpy as np
import pandas as pd
from sklearn.metrics import classification_report
from sklearn.metrics.pairwise import cosine_similarity

# Average the embeddings of all documents in each target label
df = pd.DataFrame(np.hstack([train_embeddings, np.array(data["train"]["label"]).reshape(-1, 1)]))
averaged_target_embeddings = df.groupby(768).mean().values

# Find the best matching embeddings between evaluation documents and target embeddings
sim_matrix = cosine_similarity(test_embeddings, averaged_target_embeddings)
y_pred = np.argmax(sim_matrix, axis=1)

# Evaluate the model
evaluate_performance(data["test"]["label"], y_pred)

零样本分类(Zero-shot Classification)

嵌入方法最迷人的一点:根本不需要训练!只要给每个标签写一句描述,把描述也变成嵌入,看文档与哪个标签描述更接近:

python
# Create embeddings for our labels
label_embeddings = model.encode(["A negative review",  "A positive review"])
python
from sklearn.metrics.pairwise import cosine_similarity

# Find the best matching label for each document
sim_matrix = cosine_similarity(test_embeddings, label_embeddings)
y_pred = np.argmax(sim_matrix, axis=1)
python
evaluate_performance(data["test"]["label"], y_pred)

Tip!

如果换用不同的描述会怎样?试试 "A very negative movie review""A very positive movie review",看看性能会发生什么变化!

4.4 使用生成模型做文本分类

4.4.1 Encoder-decoder 模型

生成模型走的是另一条路:让模型生成答案而不是输出分数。这里使用 encoder-decoder 架构的 google/flan-t5-small

python
# Load our model
pipe = pipeline(
    "text2text-generation",
    model="google/flan-t5-small",
    device="cuda:0"
)
python
# Prepare our data
prompt = "Is the following sentence positive or negative? "
data = data.map(lambda example: {"t5": prompt + example['text']})
data
python
# Run inference
y_pred = []
for output in tqdm(pipe(KeyDataset(data["test"], "t5")), total=len(data["test"])):
    text = output[0]["generated_text"]
    y_pred.append(0 if text == "negative" else 1)
python
evaluate_performance(data["test"]["label"], y_pred)

注意解析逻辑:模型生成的文本恰好是 "negative" 就记 0,其余情况记 1——生成模型的输出是自由文本,需要这样的映射规则。

4.4.2 用 ChatGPT 做分类

最后尝试闭源的 OpenAI API。先创建客户端:

python
import openai

# Create client
client = openai.OpenAI(api_key="YOUR_KEY_HERE")

定义一个生成函数,把系统提示与用户提示组装后调用 API:

python
def chatgpt_generation(prompt, document, model="gpt-3.5-turbo-0125"):
    """Generate an output based on a prompt and an input document."""
    messages=[
        {
            "role": "system",
            "content": "You are a helpful assistant."
            },
        {
            "role": "user",
            "content":   prompt.replace("[DOCUMENT]", document)
            }
    ]
    chat_completion = client.chat.completions.create(
      messages=messages,
      model=model,
      temperature=0
    )
    return chat_completion.choices[0].message.content
python
# Define a prompt template as a base
prompt = """Predict whether the following document is a positive or negative movie review:

[DOCUMENT]

If it is positive return 1 and if it is negative return 0. Do not give any other answers.
"""

# Predict the target using GPT
document = "unpretentious , charming , quirky , original"
chatgpt_generation(prompt, document)

下一步是对整个评估集跑 OpenAI 模型。不过请确认你有足够的额度再运行——它会对整个测试集(1066 条记录)逐一调用 API:

python
# You can skip this if you want to save your (free) credits
predictions = [chatgpt_generation(prompt, doc) for doc in tqdm(data["test"]["text"])]
python
# Extract predictions
y_pred = [int(pred) for pred in predictions]

# Evaluate performance
evaluate_performance(data["test"]["label"], y_pred)

4.5 本章小结

  • 文本分类有两条路线:表示模型(嵌入/打分)与生成模型(生成答案文本);
  • 任务专用模型(twitter-roberta-base-sentiment-latest)开箱即用,但它输出三分类,需要自行合并为二分类决策;
  • 嵌入 + LogisticRegression 是强力的监督基线;对各类嵌入求平均再做余弦相似度,可以完全不用分类器;
  • 零样本分类只需给标签写描述并编码成嵌入,无需任何训练;描述的措辞会影响效果;
  • flan-t5 这类 encoder-decoder 模型生成自由文本,需要字符串映射规则转成标签;ChatGPT 方式灵活但按调用量计费。

🧪 随堂测验

点击你认为正确的选项。答错时会展示正确答案与原因解析。

1. 本章使用的 rotten_tomatoes 数据集是什么类型的任务?

2. cardiffnlp/twitter-roberta-base-sentiment-latest 输出三个类别,而任务是二分类,代码如何处理?

3. 本章的零样本分类是如何实现的?

4. 使用 flan-t5 做分类时,代码如何把生成文本转换为标签?

🛠️ 动手实践

  1. 按 4.3.2 节的 Tip 提示,把零样本标签描述换成 "A very negative movie review""A very positive movie review",重新计算 classification_report,与原描述的 F1 分数对比并总结措辞对零样本分类的影响。
  2. 把嵌入模型从 all-mpnet-base-v2 换成 sentence-transformers/all-MiniLM-L6-v2,重跑「嵌入 + LogisticRegression」全流程,对比两个模型在测试集上的准确率与推理耗时。
  3. 用 4.4.2 节的 prompt 模板对 data["train"][0] 的评论调用 chatgpt_generation,然后手工构造 3 个对抗样例(如反讽评论),观察 GPT-3.5 的判断是否正确并记录失败模式。