BERT 中文电商评论情感分析项目 —— 逐行代码详解

本文档面向零基础小白,对项目中每一行代码进行详细解释,并拓展必要的背景知识。


目录

  1. 项目总览
  2. 你需要知道的前置知识
  3. 代码文件一:config.py —— 配置中心
  4. 代码文件二:preprocess.py —— 数据预处理
  5. 代码文件三:dataset.py —— 数据加载器
  6. 代码文件四:train.py —— 模型训练
  7. 代码文件五:evaluate.py —— 模型评估
  8. 代码文件六:predict.py —— 预测与交互
  9. 数据文件说明
  10. 完整运行流程总结
  11. 常见问题 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)?

微调 = 在预训练模型的基础上,用我们的特定数据再训练一小段时间。

具体来说:

  1. BERT 已经学会了"理解中文"
  2. 我们在 BERT 最后加一个简单的分类层(判断好评/差评)
  3. 用我们的评论数据训练这个分类层,同时微调 BERT 的参数
  4. 因为 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) 是深度学习的核心算法。

通俗理解:

  1. 前向传播:数据从输入层 → 隐藏层 → 输出层,得到预测结果
  2. 计算损失:比较预测结果和真实结果的差距
  3. 反向传播:从输出层 → 隐藏层 → 输入层,计算每个参数对损失的贡献(梯度)
  4. 更新参数:根据梯度调整参数,让损失变小

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():

为什么要禁用梯度?
评估时不需要更新参数,所以不需要计算梯度。禁用梯度计算可以:

  1. 节省内存(不需要存储梯度)
  2. 加速计算

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?

  1. BERT 最大支持 512 个 token,但越长计算量越大
  2. 电商评论通常不长,128 已经足够覆盖大部分评论
  3. 如果设太大,训练会很慢且浪费内存

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 的基本数据类型

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐