NLP系列之BERT 中文电商评论情感分析项目
BERT 中文电商评论情感分析项目 —— 逐行代码详解
本文档面向零基础小白,对项目中每一行代码进行详细解释,并拓展必要的背景知识。
目录
- 项目总览
- 你需要知道的前置知识
- 代码文件一:config.py —— 配置中心
- 代码文件二:preprocess.py —— 数据预处理
- 代码文件三:dataset.py —— 数据加载器
- 代码文件四:train.py —— 模型训练
- 代码文件五:evaluate.py —— 模型评估
- 代码文件六:predict.py —— 预测与交互
- 数据文件说明
- 完整运行流程总结
- 常见问题 FAQ
1. 项目总览
这个项目是做什么的?
这是一个中文电商评论情感分析项目。简单来说,就是让电脑自动判断一条评论是"好评"还是"差评"。
例如:
- 输入:
"这个手机质量很好,拍照也很清晰"→ 输出:好评 - 输入:
"做工太差了,用了一天就坏了"→ 输出:差评
项目结构
review_analysis_bert4sc/
├── data/
│ ├── raw/ ← 原始数据(CSV文件,6万多条评论)
│ └── processed/ ← 预处理后的数据(模型能理解的数字格式)
├── logs/ ← 训练日志(TensorBoard可视化用)
├── models/ ← 训练好的模型存放位置
└── src/ ← 源代码(你主要看的就是这里)
├── config.py ← 配置文件(路径、参数等)
├── preprocess.py ← 数据预处理(把文字变成数字)
├── dataset.py ← 数据加载器(把数据喂给模型)
├── train.py ← 模型训练(让模型学习)
├── evaluate.py ← 模型评估(测试模型学得怎么样)
└── predict.py ← 预测(实际使用模型)
运行顺序
第1步:preprocess.py → 处理原始数据
第2步:train.py → 训练模型
第3步:evaluate.py → 评估模型
第4步:predict.py → 使用模型做预测
2. 你需要知道的前置知识
2.1 什么是 BERT?
BERT(Bidirectional Encoder Representations from Transformers)是 Google 在 2018 年发布的一个预训练语言模型。
你可以把 BERT 理解为一个已经读过海量书籍的"学霸":
- 它已经学会了中文的语法、语义、上下文关系
- 我们不需要从零教它理解中文,只需要在它的基础上进行"微调"(fine-tuning),让它学会我们特定的任务(判断好评/差评)
类比:
- 从零训练一个模型 = 从幼儿园开始教一个小孩认字、学语法、理解语义
- 使用 BERT 微调 = 请一个已经大学毕业的人来做一份新工作,只需要培训他了解这份工作的具体内容
2.2 什么是分词器(Tokenizer)?
电脑不认识文字,只认识数字。分词器就是把文字转换成数字的工具。
例如:
输入:"我 喜欢 这个 手机"
输出:[101, 2769, 1599, 682, 3696, 102]
其中:
101是特殊标记[CLS](表示句子开头)102是特殊标记[SEP](表示句子结尾)- 中间的数字对应词典中每个字/词的编号
BERT 使用的是 WordPiece 分词法,会把不认识的词拆成更小的片段。例如 "喜" 可能是已知的 token,而 "喜欢" 可能被拆成 ["喜", "##欢"](## 表示这是词的后续部分)。
2.3 什么是微调(Fine-tuning)?
微调 = 在预训练模型的基础上,用我们的特定数据再训练一小段时间。
具体来说:
- BERT 已经学会了"理解中文"
- 我们在 BERT 最后加一个简单的分类层(判断好评/差评)
- 用我们的评论数据训练这个分类层,同时微调 BERT 的参数
- 因为 BERT 已经很强了,所以只需要很少的训练就能达到很好的效果
2.4 什么是 PyTorch?
PyTorch 是 Facebook 开发的深度学习框架,是目前最流行的深度学习工具之一。
核心概念:
- Tensor(张量):就是多维数组,和 NumPy 的 ndarray 类似,但可以在 GPU 上运算
- 模型(Model):定义了数据从输入到输出的计算过程
- 损失函数(Loss):衡量模型预测结果和真实结果之间的差距
- 优化器(Optimizer):根据损失来调整模型参数,让模型越来越准
2.5 什么是 Hugging Face?
Hugging Face 是一家公司,他们做了一个叫 transformers 的开源库,里面有成千上万个预训练模型(包括各种版本的 BERT)。
使用 Hugging Face,加载一个 BERT 模型只需要一行代码:
from transformers import AutoModel
model = AutoModel.from_pretrained('bert-base-chinese')
2.6 什么是 GPU/CUDA?
- GPU(图形处理器):本来是用来玩游戏的,但因为能同时做大量计算,特别适合深度学习
- CUDA:NVIDIA 提供的编程接口,让程序可以利用 NVIDIA 显卡加速计算
- device:代码中
torch.device('cuda')就是告诉程序"用 GPU 计算",如果没有 GPU 就用 CPU
3. 代码文件一:config.py —— 配置中心
文件路径:
src/config.py
作用:统一管理所有路径和参数,其他文件都从这里读取配置
3.1 完整代码
from pathlib import Path
# ==================== 目录配置 ====================
ROOT_DIR = Path(__file__).parent.parent
DATA_DIR = ROOT_DIR / 'data'
RAW_DATA_DIR = DATA_DIR / 'raw'
PROCESSED_DATA_DIR = DATA_DIR / 'processed'
MODEL_DIR = ROOT_DIR / 'models'
LOG_DIR = ROOT_DIR / 'logs'
# ==================== 文件配置 ====================
RAW_DATA_FILE = 'online_shopping_10_cats.csv'
MODEL_NAME = '../../mybert'
# ==================== 训练超参数配置 ====================
EPOCHS = 50
BATCH_SIZE = 16
LEARNING_RATE = 1e-5
MAX_SEQ_LEN = 128
3.2 逐行详解
第1行:导入 Path
from pathlib import Path
pathlib是 Python 3.4+ 内置的文件路径处理模块Path类让你可以用面向对象的方式操作文件路径,比传统的字符串拼接更优雅、更安全
传统方式 vs pathlib 方式对比:
# 传统方式(容易出错,Windows和Mac路径分隔符不同)
path = os.path.join(ROOT_DIR, 'data', 'raw')
# pathlib方式(自动处理路径分隔符,推荐!)
path = ROOT_DIR / 'data' / 'raw'
第6行:获取项目根目录
ROOT_DIR = Path(__file__).parent.parent
__file__:这是一个特殊变量,代表当前 Python 文件的路径。比如当前文件是F:\PycharmProjects\nlp_tutorial\ch07_pretrained_models\review_analysis_bert4sc\src\config.py,那么__file__就是这个字符串。Path(__file__):把这个字符串转成 Path 对象.parent:获取上一级目录。第一次.parent得到src/目录.parent.parent:再往上一级,得到项目根目录review_analysis_bert4sc/
图示:
review_analysis_bert4sc/ ← ROOT_DIR(根目录)
├── data/ ← 存数据的地方
├── models/ ← 存模型的地方
├── logs/ ← 存日志的地方
└── src/
└── config.py ← __file__(当前文件)
↑ parent → src/
↑ parent.parent → review_analysis_bert4sc/
第9-17行:目录配置
DATA_DIR = ROOT_DIR / 'data' # 数据总目录
RAW_DATA_DIR = DATA_DIR / 'raw' # 原始数据目录
PROCESSED_DATA_DIR = DATA_DIR / 'processed' # 预处理后的数据目录
MODEL_DIR = ROOT_DIR / 'models' # 模型保存目录
LOG_DIR = ROOT_DIR / 'logs' # 日志目录
这里用 / 运算符拼接路径。ROOT_DIR / 'data' 等价于 os.path.join(ROOT_DIR, 'data')。
第22行:原始数据文件名
RAW_DATA_FILE = 'online_shopping_10_cats.csv'
这是一个包含 6.2 万条电商评论的 CSV 文件,有三列:
cat:商品类别(如衣服、手机、水果等)label:情感标签(0=差评,1=好评)review:评论文本
第27行:预训练模型名称
MODEL_NAME = '../../mybert'
- 这里指向一个本地下载好的 BERT 中文模型
- 被注释掉的
'google-bert/bert-base-chinese'是在线版本,需要联网下载 - 使用本地版本可以避免下载等待和网络问题
第32-38行:训练超参数
EPOCHS = 50 # 训练总轮数
BATCH_SIZE = 16 # 批次大小
LEARNING_RATE = 1e-5 # 学习率
MAX_SEQ_LEN = 128 # 最大序列长度
超参数详解:
| 参数 | 值 | 含义 | 通俗解释 |
|---|---|---|---|
EPOCHS |
50 | 训练轮数 | 把所有训练数据完整看 50 遍 |
BATCH_SIZE |
16 | 批次大小 | 每次看 16 条数据就更新一次参数 |
LEARNING_RATE |
1e-5 (0.00001) | 学习率 | 每次参数更新的步子大小。步子太大容易跨过最优解,太小训练太慢 |
MAX_SEQ_LEN |
128 | 最大序列长度 | 一条评论最多保留 128 个字(多的截断,少的补零) |
为什么学习率这么小?
因为 BERT 已经预训练好了,我们只是微调。如果学习率太大,会把预训练学到的知识"冲掉"。就像你已经会骑自行车了,只需要稍微适应一下新的变速自行车,不需要重新学骑车。
4. 代码文件二:preprocess.py —— 数据预处理
文件路径:
src/preprocess.py
作用:把原始的 CSV 文本数据转换成模型能理解的数字格式
4.1 完整代码
from datasets import load_dataset, ClassLabel
from config import *
from transformers import AutoTokenizer
def preprocess():
print("Preprocessing data...")
# 步骤1:读取原始CSV数据文件
dataset = load_dataset('csv', data_files=str(RAW_DATA_DIR / RAW_DATA_FILE))['train']
# 步骤2:数据清洗
dataset = dataset.remove_columns(['cat'])
dataset = dataset.filter(lambda x: x['review'] is not None)
print(dataset)
# 步骤3:划分训练集和测试集
dataset = dataset.cast_column('label', ClassLabel(names=['neg', 'pos']))
dataset_dict = dataset.train_test_split(test_size=0.2, stratify_by_column='label')
print(dataset_dict)
# 步骤4:创建分词器
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
# 步骤5:编码
def encode(batch):
inputs = tokenizer(
batch['review'],
padding='max_length',
max_length=MAX_SEQ_LEN,
truncation=True,
)
inputs['labels'] = batch['label']
return inputs
dataset_dict = dataset_dict.map(encode, batched=True, remove_columns=['label', 'review'])
# 步骤6:保存
dataset_dict.save_to_disk(PROCESSED_DATA_DIR)
print("Preprocessing done.")
if __name__ == '__main__':
preprocess()
4.2 逐行详解
第1-2行:导入模块
from datasets import load_dataset, ClassLabel
from config import *
load_dataset:Hugging Face 的数据集加载函数,支持 CSV、JSON、文本等多种格式ClassLabel:定义分类标签的类型,告诉模型这是一个分类任务from config import *:导入 config.py 中的所有配置(*表示全部导入)
关于 from xxx import *:
这种写法会把模块中所有不以 _ 开头的变量/函数都导入进来。在大型项目中不推荐(容易命名冲突),但在这个小项目中很方便。
第5行:导入分词器
from transformers import AutoTokenizer
AutoTokenizer:自动分词器,会根据你指定的模型名称自动加载对应的分词器- 不同的 BERT 模型使用不同的分词器,
AutoTokenizer帮你自动匹配
第9行:函数定义
def preprocess():
定义数据预处理的主函数。
第12-13行:加载原始数据
dataset = load_dataset('csv', data_files=str(RAW_DATA_DIR / RAW_DATA_FILE))['train']
分解:
load_dataset('csv', data_files=...):加载 CSV 格式的数据文件str(RAW_DATA_DIR / RAW_DATA_FILE):把 Path 对象转成字符串。路径类似review_analysis_bert4sc/data/raw/online_shopping_10_cats.csv['train']:load_dataset 返回一个 DatasetDict(字典),CSV 加载后默认放在 ‘train’ 键下
数据加载后的样子:
Dataset({
features: ['cat', 'label', 'review'],
num_rows: 62774
})
这是一个表格,有 62774 行,3 列。
第16-17行:数据清洗
dataset = dataset.remove_columns(['cat'])
dataset = dataset.filter(lambda x: x['review'] is not None)
remove_columns(['cat']):删除cat(商品类别)列。因为我们做的是情感分析,不需要知道是什么商品filter(lambda x: x['review'] is not None):过滤掉评论为空的行
什么是 lambda?lambda x: x['review'] is not None 是一个匿名函数,等价于:
def filter_func(x):
return x['review'] is not None
dataset = dataset.filter(filter_func)
lambda 更简洁,适合这种简单的、只用一次的函数。
第22-23行:划分训练集和测试集
dataset = dataset.cast_column('label', ClassLabel(names=['neg', 'pos']))
dataset_dict = dataset.train_test_split(test_size=0.2, stratify_by_column='label')
第1行:把 label 列从普通数字转为 ClassLabel 类型
ClassLabel(names=['neg', 'pos']):告诉系统标签有 2 个类别,0 代表 ‘neg’(差评),1 代表 ‘pos’(好评)- 这样做的好处是系统知道这是分类任务,可以做分层抽样
第2行:划分训练集和测试集
test_size=0.2:20% 作为测试集,80% 作为训练集stratify_by_column='label':分层抽样,保证训练集和测试集中好评/差评的比例一致
为什么需要分层抽样?
假设数据中有 60% 好评、40% 差评。如果不分层随机抽样,可能出现测试集中 80% 都是好评的情况,这样测试结果就不准确。分层抽样确保训练集和测试集中好评/差评的比例都是 60:40。
划分后的样子:
DatasetDict({
train: Dataset({features: [...], num_rows: ~50219})
test: Dataset({features: [...], num_rows: ~12555})
})
第26行:创建分词器
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
根据 MODEL_NAME(即 '../../mybert')加载对应的中文 BERT 分词器。
这个分词器内部包含:
- 一个词典(vocab),约 21128 个中文字符/符号
- 分词规则(WordPiece 算法)
- 特殊 token 的定义([CLS]、[SEP]、[PAD] 等)
第29-38行:定义编码函数
def encode(batch):
inputs = tokenizer(
batch['review'],
padding='max_length',
max_length=MAX_SEQ_LEN,
truncation=True,
)
inputs['labels'] = batch['label']
return inputs
这是核心函数,把文本转换成模型能理解的数字。
tokenizer 参数详解:
| 参数 | 值 | 含义 |
|---|---|---|
batch['review'] |
评论文本列表 | 要编码的文本 |
padding='max_length' |
填充模式 | 短于 max_length 的序列用 [PAD] 填充到 max_length |
max_length=128 |
最大长度 | 每条评论最多 128 个 token |
truncation=True |
截断 | 超过 128 个 token 的部分直接切掉 |
编码过程示例:
原始文本:"这个手机质量很好"
↓ 分词
tokens: ["[CLS]", "这", "个", "手", "机", "质", "量", "很", "好", "[SEP]"]
↓ 转ID
input_ids: [101, 682, 702, 2797, 3696, 6574, 702, 2523, 1962, 102]
↓ 填充到128
input_ids: [101, 682, 702, 2797, 3696, 6574, 702, 2523, 1962, 102, 0, 0, 0, ..., 0]
↑ 后面补118个0([PAD]的ID)
attention_mask: [1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, ..., 0]
↑ 有效token为1,填充的PAD为0(告诉模型忽略PAD)
token_type_ids: [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, ..., 0]
↑ 单句任务全部为0(区分句子A和句子B用的,这里只有一句)
attention_mask 的作用:
模型在计算注意力时,需要知道哪些位置是真实数据,哪些是填充的。attention_mask 中 1 表示"请注意这个位置",0 表示"忽略这个位置"。
最后把标签也加进去:
inputs['labels'] = batch['label']
第41行:批量编码
dataset_dict = dataset_dict.map(encode, batched=True, remove_columns=['label', 'review'])
map(encode, batched=True):对数据集中的每个批次应用encode函数batched=True:一次传入一批数据(而不是逐条),效率更高remove_columns=['label', 'review']:删除原始的文本列和标签列(因为编码后已经有了新的数字格式)
第44行:保存数据
dataset_dict.save_to_disk(PROCESSED_DATA_DIR)
把处理好的数据保存到 data/processed/ 目录,使用 Apache Arrow 格式(一种高效的列式存储格式)。
保存后的目录结构:
data/processed/
├── dataset_dict.json ← 记录有哪些split(train/test)
├── train/
│ ├── data-00000-of-00001.arrow ← 训练数据(二进制格式)
│ ├── dataset_info.json ← 数据集元信息
│ └── state.json ← 状态信息
└── test/
├── data-00000-of-00001.arrow ← 测试数据
├── dataset_info.json
└── state.json
5. 代码文件三:dataset.py —— 数据加载器
文件路径:
src/dataset.py
作用:把预处理好的数据加载出来,按批次喂给模型
5.1 完整代码
import torch
from torch.utils.data import DataLoader
from config import *
from datasets import load_from_disk
def get_dataloader(train=True):
data_path = PROCESSED_DATA_DIR / ('train' if train else 'test')
dataset = load_from_disk(data_path)
dataset.set_format(type='torch')
dataloader = DataLoader(
dataset,
batch_size=BATCH_SIZE,
shuffle=train
)
return dataloader
if __name__ == '__main__':
train_loader = get_dataloader(train=True)
test_loader = get_dataloader(train=False)
print(len(train_loader))
print(len(test_loader))
for batch in train_loader:
print(batch)
break
data_iter = iter(test_loader)
batch = next(data_iter)
print(batch)
5.2 逐行详解
第1-2行:导入
import torch
from torch.utils.data import DataLoader
torch:PyTorch 核心库DataLoader:PyTorch 的数据加载器,负责把数据集按批次取出、打乱顺序等
第5行:加载配置
from config import *
导入所有配置项。
第7行:导入数据集加载函数
from datasets import load_from_disk
load_from_disk:从磁盘加载之前用 save_to_disk 保存的 Hugging Face 数据集。
第10-11行:函数定义和路径选择
def get_dataloader(train=True):
data_path = PROCESSED_DATA_DIR / ('train' if train else 'test')
train=True:返回训练集加载器train=False:返回测试集加载器- 三元表达式
'train' if train else 'test':如果 train 为 True 就选 ‘train’ 目录,否则选 ‘test’ 目录
第13-14行:加载数据集并设置格式
dataset = load_from_disk(data_path)
dataset.set_format(type='torch')
load_from_disk(data_path):从磁盘加载 Arrow 格式的数据集set_format(type='torch'):告诉数据集返回 PyTorch 张量(Tensor),而不是普通的 Python 列表
为什么要用 Tensor?
PyTorch 的模型只能接受 Tensor 类型的输入。Tensor 可以在 GPU 上运算,比普通列表快很多。
第16-20行:创建 DataLoader
dataloader = DataLoader(
dataset,
batch_size=BATCH_SIZE,
shuffle=train
)
return dataloader
DataLoader 参数详解:
| 参数 | 值 | 含义 |
|---|---|---|
dataset |
数据集 | 要加载的数据 |
batch_size |
16 | 每次取 16 条数据 |
shuffle |
train |
训练集打乱顺序(True),测试集不打乱(False) |
为什么要打乱训练集?
打乱顺序可以让模型看到不同类型的样本交替出现,避免模型学到数据的顺序规律(比如前半段全是好评,后半段全是差评)。
DataLoader 的作用:
假设数据集有 50000 条数据,batch_size=16,那么 DataLoader 会把它分成 50000/16 ≈ 3125 个批次。每次取一个批次(16条数据),而不是一次性把 50000 条都放进内存。
第24-34行:测试代码
if __name__ == '__main__':
train_loader = get_dataloader(train=True)
test_loader = get_dataloader(train=False)
print(len(train_loader)) # 打印训练集有多少个批次
print(len(test_loader)) # 打印测试集有多少个批次
for batch in train_loader: # 取一个训练批次
print(batch)
break
data_iter = iter(test_loader) # 创建迭代器
batch = next(data_iter) # 取下一个批次
print(batch)
if __name__ == '__main__' 是什么意思?
这是 Python 的一个约定:只有直接运行这个文件时,才会执行里面的代码。如果这个文件被其他文件 import,里面的代码不会执行。
一个 batch 长什么样?
{
'input_ids': tensor([[101, 682, 702, ..., 0, 0], # 第1条数据
[101, 2769, 1599, ..., 0, 0], # 第2条数据
...]), # 共16条
'token_type_ids': tensor([[0, 0, 0, ..., 0, 0],
[0, 0, 0, ..., 0, 0],
...]),
'attention_mask': tensor([[1, 1, 1, ..., 0, 0],
[1, 1, 1, ..., 0, 0],
...]),
'labels': tensor([1, 0, 1, 1, 0, ...]) # 16个标签
}
每个 tensor 的第一维都是 16(batch_size),第二维是 128(MAX_SEQ_LEN)。
6. 代码文件四:train.py —— 模型训练
文件路径:
src/train.py
作用:用预处理好的数据训练 BERT 模型
6.1 完整代码
import time
import torch
from torch import nn, optim
from config import *
from dataset import get_dataloader
from transformers import AutoModelForSequenceClassification
from tqdm import tqdm
from torch.utils.tensorboard import SummaryWriter
def train_one_epoch(model, train_loader, optimizer, device):
model.train()
total_loss = 0
for batch in tqdm(train_loader, desc='Train'):
inputs = {k: v.to(device) for k, v in batch.items()}
outputs = model(**inputs)
loss = outputs.loss
loss.backward()
optimizer.step()
optimizer.zero_grad()
total_loss += loss.item()
return total_loss / len(train_loader)
def train():
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
train_loader = get_dataloader()
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME).to(device)
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
writer = SummaryWriter(log_dir=LOG_DIR / time.strftime("%Y-%m-%d_%H-%M-%S"))
min_loss = float('inf')
for epoch in range(EPOCHS):
tqdm.write(f'Epoch {epoch + 1}')
train_loss = train_one_epoch(model, train_loader, optimizer, device)
tqdm.write(f'Train Loss: {train_loss}')
writer.add_scalar('Train Loss', train_loss, epoch + 1)
if train_loss < min_loss:
min_loss = train_loss
model.save_pretrained(MODEL_DIR)
tqdm.write("Best model saved")
writer.close()
if __name__ == '__main__':
train()
6.2 逐行详解
第1-2行:导入
import time
import torch
time:用于生成时间戳,给日志目录命名torch:PyTorch 核心库
第4-5行:导入更多模块
from torch import nn, optim
from config import *
nn:神经网络模块(虽然这里没有直接用到 nn 的东西,但导入是为了可能的扩展)optim:优化器模块
第7-10行:导入模型和工具
from dataset import get_dataloader
from transformers import AutoModelForSequenceClassification
from tqdm import tqdm
from torch.utils.tensorboard import SummaryWriter
get_dataloader:从 dataset.py 导入数据加载函数AutoModelForSequenceClassification:自动序列分类模型,会加载 BERT + 分类头tqdm:进度条库,在终端显示训练进度SummaryWriter:TensorBoard 的写入器,用于记录训练过程
第14-16行:train_one_epoch 函数开头
def train_one_epoch(model, train_loader, optimizer, device):
model.train()
total_loss = 0
model.train():告诉模型现在是训练模式。这会启用 Dropout 和 BatchNorm 等训练时才有的操作total_loss = 0:初始化总损失为 0
model.train() vs model.eval():
model.train():训练模式。Dropout 层会随机丢弃一些神经元,BatchNorm 使用当前批次的统计量model.eval():评估模式。Dropout 不丢弃神经元,BatchNorm 使用训练时积累的统计量
第18-19行:遍历批次
for batch in tqdm(train_loader, desc='Train'):
inputs = {k: v.to(device) for k, v in batch.items()}
tqdm(train_loader, desc='Train'):给数据加载器包一层进度条,desc='Train'是进度条前的描述文字{k: v.to(device) for k, v in batch.items()}:字典推导式,把 batch 中的每个 tensor 都转移到指定设备(GPU 或 CPU)
字典推导式详解:
# 等价于:
inputs = {}
for k, v in batch.items():
inputs[k] = v.to(device)
# k 是键名(如 'input_ids'),v 是对应的 tensor
# .to(device) 把 tensor 移到 GPU 或 CPU
第22-23行:前向传播
outputs = model(**inputs)
loss = outputs.loss
-
model(**inputs):把输入数据传入模型。**inputs会把字典展开为关键字参数,等价于:model( input_ids=inputs['input_ids'], attention_mask=inputs['attention_mask'], token_type_ids=inputs['token_type_ids'], labels=inputs['labels'] ) -
outputs.loss:因为传入了labels,模型会自动计算交叉熵损失
什么是 logits?outputs.logits 是模型最后一层的输出(未经 softmax 的原始分数),形状为 (batch_size, num_labels),即 (16, 2)。每一行有两个数字,分别代表"差评"和"好评"的原始分数。
什么是交叉熵损失?
交叉熵是分类任务中最常用的损失函数。它衡量模型预测的概率分布和真实标签之间的差距:
- 模型预测 “好评” 的概率是 0.9,真实标签是 “好评”(1)→ 损失小
- 模型预测 “好评” 的概率是 0.1,真实标签是 “好评”(1)→ 损失大
第26行:反向传播
loss.backward()
反向传播(Backpropagation) 是深度学习的核心算法。
通俗理解:
- 前向传播:数据从输入层 → 隐藏层 → 输出层,得到预测结果
- 计算损失:比较预测结果和真实结果的差距
- 反向传播:从输出层 → 隐藏层 → 输入层,计算每个参数对损失的贡献(梯度)
- 更新参数:根据梯度调整参数,让损失变小
loss.backward() 就是执行第 3 步,计算所有参数的梯度。
第29行:更新参数
optimizer.step()
根据计算好的梯度更新模型参数。Adam 优化器的更新公式(简化版):
新参数 = 旧参数 - 学习率 × 梯度方向
第32行:梯度清零
optimizer.zero_grad()
为什么要梯度清零?
PyTorch 默认会累加梯度。如果不清零,下一次 backward 的梯度会和这次的加在一起,导致参数更新不正确。所以每次更新后都要清零。
第35-36行:累加损失和返回
total_loss += loss.item()
return total_loss / len(train_loader)
loss.item():把 tensor 中的数值取出来(变成普通的 Python 数字)total_loss / len(train_loader):计算平均损失
第40-42行:train 函数开头
def train():
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
train_loader = get_dataloader()
model = AutoModelForSequenceClassification.from_pretrained(MODEL_NAME).to(device)
torch.device(...):选择计算设备get_dataloader():获取训练数据加载器AutoModelForSequenceClassification.from_pretrained(MODEL_NAME):加载预训练模型
模型结构:
AutoModelForSequenceClassification = BERT + 线性分类层
输入文本
↓
[CLS] token + 各字的表示
↓
BERT 编码器(12层 Transformer)
↓
[CLS] 位置的输出向量(768维)
↓
线性分类层(768 → 2)
↓
[差评分数, 好评分数]
.to(device):把模型移到 GPU(如果有的话)。
第45行:定义优化器
optimizer = optim.Adam(model.parameters(), lr=LEARNING_RATE)
model.parameters():获取模型所有需要训练的参数lr=LEARNING_RATE:学习率(1e-5)
Adam 优化器:
Adam 是目前最常用的优化器之一,它结合了两种优化方法的优点:
- Momentum(动量):在梯度方向一致的方向上加速,在震荡的方向上减速
- RMSprop:自适应调整每个参数的学习率
第48行:创建 TensorBoard 写入器
writer = SummaryWriter(log_dir=LOG_DIR / time.strftime("%Y-%m-%d_%H-%M-%S"))
SummaryWriter:TensorBoard 的写入器LOG_DIR / time.strftime(...):日志目录,用当前时间命名,如logs/2026-05-09_16-17-25/
TensorBoard 是什么?
TensorBoard 是一个可视化工具,可以画出训练过程中的损失曲线、准确率曲线等。使用方法:
tensorboard --logdir=logs/
然后在浏览器中打开 http://localhost:6006 就能看到图表。
第51-52行:训练循环开始
min_loss = float('inf')
for epoch in range(EPOCHS):
min_loss = float('inf'):初始化最小损失为正无穷大。后续会记录训练过程中最小的损失,用来保存最优模型for epoch in range(EPOCHS):循环 50 次(EPOCHS=50)
第53-54行:训练一个 epoch
tqdm.write(f'Epoch {epoch + 1}')
train_loss = train_one_epoch(model, train_loader, optimizer, device)
tqdm.write(...):在进度条上方打印信息(不影响进度条显示)train_one_epoch(...):训练一个 epoch,返回平均损失
第56-57行:打印损失
tqdm.write(f'Train Loss: {train_loss}')
第60行:记录到 TensorBoard
writer.add_scalar('Train Loss', train_loss, epoch + 1)
'Train Loss':图表的标题train_loss:数值epoch + 1:x 轴(第几个 epoch)
第63-67行:保存最优模型
if train_loss < min_loss:
min_loss = train_loss
model.save_pretrained(MODEL_DIR)
tqdm.write("Best model saved")
- 如果当前损失比历史最小损失还小,就保存模型
model.save_pretrained(MODEL_DIR):保存模型到models/目录
save_pretrained 会保存什么?
models/
├── config.json ← 模型配置(层数、隐藏维度等)
└── model.safetensors ← 模型权重(参数数值)
为什么只保存最优模型?
因为训练过程中,模型可能在某些 epoch 效果变差(过拟合)。只保存损失最小的模型,可以确保得到的是训练过程中最好的版本。
第70行:关闭 TensorBoard 写入器
writer.close()
确保所有日志都写入磁盘。
7. 代码文件五:evaluate.py —— 模型评估
文件路径:
src/evaluate.py
作用:用测试集评估训练好的模型的准确率
7.1 完整代码
import torch
from tqdm import tqdm
from config import *
from transformers import AutoModelForSequenceClassification
from predict import predict_batch
from dataset import get_dataloader
def evaluate(model, test_loader, device):
model.eval()
correct_num = 0
total_num = 0
with torch.no_grad():
for batch in tqdm(test_loader, desc='Evaluating'):
labels = batch.pop('labels').tolist()
inputs = {k: v.to(device) for k, v in batch.items()}
batch_probs = predict_batch(model, inputs, device)
for prob, label in zip(batch_probs, labels):
result = 1 if prob > 0.5 else 0
if result == label:
correct_num += 1
total_num += 1
return correct_num / total_num
def run_evaluate():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = AutoModelForSequenceClassification.from_pretrained(MODEL_DIR).to(device)
print("模型加载成功!")
test_loader = get_dataloader(train=False)
acc = evaluate(model, test_loader, device)
print("评估结果-准确率:", acc)
if __name__ == '__main__':
run_evaluate()
7.2 逐行详解
第13-14行:评估模式和计数器
model.eval()
correct_num = 0
total_num = 0
model.eval():切换到评估模式,关闭 Dropout 等correct_num:正确预测的数量total_num:总样本数量
第16行:禁用梯度计算
with torch.no_grad():
为什么要禁用梯度?
评估时不需要更新参数,所以不需要计算梯度。禁用梯度计算可以:
- 节省内存(不需要存储梯度)
- 加速计算
with torch.no_grad() 是 Python 的上下文管理器,在这个代码块内的所有操作都不会计算梯度。
第18-19行:取出标签
labels = batch.pop('labels').tolist()
inputs = {k: v.to(device) for k, v in batch.items()}
batch.pop('labels'):从 batch 中取出 labels,并从 batch 中删除(因为模型输入不需要 labels).tolist():把 tensor 转成 Python 列表
第22-23行:预测
batch_probs = predict_batch(model, inputs, device)
调用 predict.py 中的 predict_batch 函数,获取每个样本属于"好评"的概率。
第25-29行:统计正确数
for prob, label in zip(batch_probs, labels):
result = 1 if prob > 0.5 else 0
if result == label:
correct_num += 1
total_num += 1
zip(batch_probs, labels):把概率和标签配对result = 1 if prob > 0.5 else 0:概率大于 0.5 判为好评(1),否则判为差评(0)- 如果预测正确,
correct_num加 1
第31行:返回准确率
return correct_num / total_num
准确率 = 正确预测数 / 总样本数
第35-37行:加载模型
def run_evaluate():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
model = AutoModelForSequenceClassification.from_pretrained(MODEL_DIR).to(device)
从 models/ 目录加载训练好的模型(不是预训练模型)。
8. 代码文件六:predict.py —— 预测与交互
文件路径:
src/predict.py
作用:使用训练好的模型进行预测,包括批量预测和交互式预测
8.1 完整代码
import torch
from config import *
from transformers import AutoTokenizer, AutoModelForSequenceClassification
def predict_batch(model, inputs, device):
model.eval()
with torch.no_grad():
inputs = {k: v.to(device) for k, v in inputs.items()}
outputs = model(**inputs)
batch_probs = torch.softmax(outputs.logits, dim=-1)
return batch_probs[:, 1].tolist()
def predict(text, model, tokenizer, device):
input = tokenizer(
text,
padding='max_length',
max_length=MAX_SEQ_LEN,
truncation=True,
return_tensors='pt',
)
result = predict_batch(model, input, device)
return result[0]
def run_predict_app():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_DIR).to(device)
print("模型加载成功!")
print("欢迎使用评论情感分析小程序!输入 q 或者 quit 退出...")
while True:
user_input = input("评论:")
if user_input in ["q", "quit"]:
print("欢迎下次再来!")
break
if user_input.strip() == "":
print("请输入有效内容...")
continue
result = predict(user_input, model, tokenizer, device)
if result > 0.5:
print("好评,置信度:", result)
else:
print("差评,置信度:", 1 - result)
if __name__ == '__main__':
run_predict_app()
8.2 逐行详解
第9-10行:评估模式和禁用梯度
def predict_batch(model, inputs, device):
model.eval()
with torch.no_grad():
inputs = {k: v.to(device) for k, v in inputs.items()}
outputs = model(**inputs)
和 evaluate.py 一样,预测时要切换到评估模式并禁用梯度。
第16-17行:Softmax 计算概率
batch_probs = torch.softmax(outputs.logits, dim=-1)
return batch_probs[:, 1].tolist()
什么是 Softmax?
Softmax 把原始分数(logits)转换成概率分布,所有概率之和为 1。
示例:
logits: [2.0, 0.5] ← 原始分数
↓ softmax
probs: [0.82, 0.18] ← 概率(0.82 + 0.18 = 1.0)
logits: [-1.0, 3.0]
↓ softmax
probs: [0.02, 0.98]
dim=-1:在最后一个维度上做 softmax(即 2 个类别之间)batch_probs[:, 1]:取所有样本的第 2 个值(索引 1),即"好评"的概率.tolist():转成 Python 列表
第22-30行:单句预测
def predict(text, model, tokenizer, device):
input = tokenizer(
text,
padding='max_length',
max_length=MAX_SEQ_LEN,
truncation=True,
return_tensors='pt',
)
result = predict_batch(model, input, device)
return result[0]
和 preprocess.py 中的编码过程类似,但有几点不同:
return_tensors='pt':返回 PyTorch 张量(preprocess 中用的是 Hugging Face 的 map,自动处理)- 不需要
return_tensors='tf'(那是 TensorFlow 的)
第34-36行:交互式应用
def run_predict_app():
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
model = AutoModelForSequenceClassification.from_pretrained(MODEL_DIR).to(device)
加载分词器(从预训练模型)和模型(从训练好的保存目录)。
第42-56行:交互循环
print("欢迎使用评论情感分析小程序!输入 q 或者 quit 退出...")
while True:
user_input = input("评论:")
if user_input in ["q", "quit"]:
print("欢迎下次再来!")
break
if user_input.strip() == "":
print("请输入有效内容...")
continue
result = predict(user_input, model, tokenizer, device)
if result > 0.5:
print("好评,置信度:", result)
else:
print("差评,置信度:", 1 - result)
while True:无限循环,直到用户输入退出命令input("评论:"):等待用户输入user_input in ["q", "quit"]:检查是否要退出user_input.strip() == "":检查是否为空输入result > 0.5:概率大于 0.5 判为好评1 - result:差评时显示差评的置信度(不是好评概率,而是差评概率)
9. 数据文件说明
9.1 原始数据:online_shopping_10_cats.csv
这是一个包含 62,774 条中文电商评论的 CSV 文件。
数据格式:
| cat | label | review |
|---|---|---|
| hotel | 1 | 酒店环境很好,服务态度也不错 |
| clothing | 0 | 衣服质量太差了,洗了一次就缩水 |
| phone | 1 | 这个手机性价比很高,拍照也清晰 |
| … | … | … |
数据来源:公开的中文情感分析数据集
数据分布:
| 商品类别 | 好评数 | 差评数 |
|---|---|---|
| 酒店 (hotel) | 5,000 | 5,000 |
| 服装 (clothing) | 5,000 | 5,000 |
| 洗发水 (shampoo) | 5,000 | 5,000 |
| 水果 (fruit) | 5,000 | 5,000 |
| 平板 (tablet) | 5,000 | 5,000 |
| 书籍 (books) | 2,100 | 1,751 |
| 电脑 (computer) | 1,996 | 1,996 |
| 手机 (phone) | 1,165 | 1,158 |
| 蒙牛 (Mengniu) | 992 | 1,041 |
| 热水器 (water heater) | 475 | 100 |
9.2 预处理后的数据
预处理后的数据存储在 data/processed/ 目录,使用 Apache Arrow 格式。
预处理后每条数据包含:
input_ids:文本的 token ID 序列(长度 128)attention_mask:注意力掩码(1 表示有效 token,0 表示填充)token_type_ids:段落 ID(全是 0,因为只有单句)labels:情感标签(0 或 1)
10. 完整运行流程总结
流程图
┌─────────────────────────────────────────────────────────────┐
│ 原始数据 │
│ online_shopping_10_cats.csv (62774条评论) │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ preprocess.py 数据预处理 │
│ │
│ 1. 加载 CSV │
│ 2. 删除 cat 列 │
│ 3. 过滤空评论 │
│ 4. 划分训练集(80%)/测试集(20%) │
│ 5. 用 BERT 分词器编码文本 → input_ids, attention_mask, ... │
│ 6. 保存到 data/processed/ │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ train.py 模型训练 │
│ │
│ 1. 加载预训练 BERT + 分类头 │
│ 2. 循环 50 个 epoch: │
│ - 前向传播:输入 → BERT → 分类 → 损失 │
│ - 反向传播:计算梯度 │
│ - 更新参数:Adam 优化器 │
│ 3. 保存损失最小的模型到 models/ │
│ 4. 记录训练日志到 logs/ │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ evaluate.py 模型评估 │
│ │
│ 1. 加载训练好的模型 │
│ 2. 在测试集上预测 │
│ 3. 统计准确率 │
└─────────────────────┬───────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ predict.py 实际使用 │
│ │
│ 用户输入评论 → 分词 → 模型预测 → 输出好评/差评 │
└─────────────────────────────────────────────────────────────┘
各文件之间的依赖关系
config.py ← 被所有其他文件引用(提供路径和参数)
↑
preprocess.py → 生成 data/processed/(预处理后的数据)
↑
dataset.py → 读取 data/processed/,提供 DataLoader
↑
train.py → 使用 DataLoader 训练模型,保存到 models/
↑
evaluate.py → 加载 models/,使用 DataLoader 评估
↑
predict.py → 加载 models/,提供预测功能
11. 常见问题 FAQ
Q1:为什么要用 BERT 而不是自己训练一个模型?
自己训练:需要从零学习中文语法、语义,需要海量数据和强大的计算资源
使用 BERT:站在巨人肩膀上,只需要少量标注数据就能达到很好的效果
类比:
- 自己训练 = 从零教一个婴儿学中文
- 使用 BERT = 请一个中文专家来做分类工作
Q2:为什么学习率要设这么小(1e-5)?
BERT 已经预训练好了,参数已经在一个很好的位置。如果学习率太大会:
- 破坏预训练学到的知识
- 训练不稳定
1e-5 是经验值,在 BERT 微调任务中效果最好。
Q3:为什么要设 MAX_SEQ_LEN = 128?
- BERT 最大支持 512 个 token,但越长计算量越大
- 电商评论通常不长,128 已经足够覆盖大部分评论
- 如果设太大,训练会很慢且浪费内存
Q4:train_one_epoch 中为什么没有显式的损失函数?
因为 AutoModelForSequenceClassification 内部已经封装了交叉熵损失函数。当你传入 labels 参数时,模型会自动计算损失。
outputs = model(**inputs) # inputs 中包含 labels
loss = outputs.loss # 损失已经自动计算好了
Q5:为什么用 save_pretrained 而不是 torch.save?
torch.save:只保存参数的数值save_pretrained:保存参数 + 模型配置(几层、隐藏维度等)
用 save_pretrained 保存的模型,可以用 from_pretrained 一行代码加载,非常方便。
Q6:什么是 TensorBoard?怎么用?
TensorBoard 是 Google 开发的可视化工具,可以看到训练过程中的损失曲线。
使用方法:
# 在终端运行
tensorboard --logdir=logs/
# 然后在浏览器打开
http://localhost:6006
Q7:如果没有 GPU 怎么办?
代码已经处理了这种情况:
device = torch.device('cuda' if torch.cuda.is_available() else 'cpu')
如果没有 NVIDIA GPU + CUDA,会自动使用 CPU。只是训练速度会慢很多(可能需要几个小时而不是几十分钟)。
Q8:什么是分层抽样(stratify_by_column)?
假设数据中 60% 好评、40% 差评:
- 随机抽样:测试集可能 80% 都是好评 → 测试结果不准
- 分层抽样:确保测试集中也是 60% 好评、40% 差评 → 测试结果更可靠
附录:关键术语速查表
| 术语 | 英文 | 含义 |
|---|---|---|
| 预训练 | Pre-training | 在大量无标注数据上训练模型,学习通用的语言知识 |
| 微调 | Fine-tuning | 在特定任务数据上继续训练,让模型学会特定任务 |
| 分词器 | Tokenizer | 把文字转换成数字的工具 |
| 嵌入 | Embedding | 把数字映射成向量(一组数字),让模型能理解语义 |
| 注意力机制 | Attention | 让模型关注输入中最重要的部分 |
| Transformer | - | 一种基于注意力机制的神经网络架构,BERT 的核心 |
| 梯度 | Gradient | 损失函数对参数的导数,指示参数应该往哪个方向调整 |
| 反向传播 | Backpropagation | 计算梯度的算法 |
| 过拟合 | Overfitting | 模型在训练数据上表现很好,但在新数据上表现差 |
| Dropout | - | 训练时随机关闭一些神经元,防止过拟合 |
| Softmax | - | 把原始分数转换成概率分布的函数 |
| 交叉熵 | Cross Entropy | 分类任务中常用的损失函数 |
| Adam | - | 自适应学习率的优化器 |
| Batch | - | 一次训练使用的样本集合 |
| Epoch | - | 完整遍历一次训练数据 |
| Logits | - | 模型最后一层的原始输出(未经 Softmax) |
| CUDA | - | NVIDIA 的 GPU 编程接口 |
| Tensor | 张量 | 多维数组,PyTorch 的基本数据类型 |
更多推荐




所有评论(0)