第三章、文本预处理(下)
文本预处理(下)
适用读者:已阅读前两章(NLP概念、文本预处理-上)的零基础初学者。本章将学习如何"读懂数据"(探索性分析)和"改造数据"(特征工程),这是模型训练前的最后两步准备。
学习目标:
- 掌握数据探索性分析(EDA)的核心方法——用图表"看清"你的数据
- 理解n-gram特征的原理和构建方法
- 掌握序列填充(Padding)——为什么深度学习要求"统一尺寸"
- 学会调用翻译API进行数据增强或跨语言处理
- 理解数据划分策略和函数式编程工具(chain、zip)的使用
预计阅读时间:90-120分钟(包含动手练习和思考题时间)
环境准备:确保已安装pandas、seaborn、matplotlib、wordcloud、jieba等库。
目录
- 零、热身:从"看数据"到"懂数据"
- 一、数据探索性分析(EDA)全景图
- 二、标签与文本长度分析
- 三、词汇统计与词云可视化
- 四、n-gram特征——捕捉局部语序信息
- 五、序列填充——统一输入尺寸
- 六、翻译API——跨语言的桥梁
- 七、Python函数式编程工具详解
- 八、补充提升内容
- 九、本章总结与自测题
热身:从"看数据"到"懂数据"
一个真实的教训
假设有这样一个场景:
小明花了3天时间,用自己写的NLP模型训练情感分类器。
模型在训练集上的准确率达到了99%,他非常兴奋。
但上线后,准确率只有60%——连随机猜测都不如。
小明检查后发现:
- 训练集中90%的样本都是"正面"情感
- 模型学会了"偷懒":不管输入是什么,都预测"正面"
- 这样轻松获得90%的正确率
如果小明在训练前花30分钟做EDA(探索性数据分析),
他会在第一步就发现"类别不均衡"这个问题。
这个故事告诉我们:在模型训练前"理解你的数据",比模型训练本身更重要。
EDA = 数据的"体检报告"
EDA (Exploratory Data Analysis) 探索性数据分析
就像你去医院体检:
量身高体重 → 查看数据的基本统计量(均值、方差等)
抽血化验 → 分析标签分布是否均衡
拍X光片 → 用可视化图表看到数据的深层结构
医生诊断 → 发现问题并制定处理方案
体检完你才知道:
- 有没有"偏科"(类别不均衡)
- 有没有"营养不良"(数据量不够)
- 有没有"隐藏疾病"(异常样本)
常见的文本数据文件格式
在进入具体分析之前,先了解你可能会遇到的文本数据存储格式:
| 格式 | 说明 | 示例 | 适合场景 |
|---|---|---|---|
| TSV | Tab分隔值,用制表符\t分隔列 |
label\ttext |
文本数据(文本中可能有逗号但不会有制表符) |
| CSV | 逗号分隔值,用逗号,分隔列 |
label,text |
通用表格数据 |
| JSON | JavaScript对象表示法 | {"label": 1, "text": "..."} |
复杂嵌套结构 |
| TXT | 纯文本,一行一个样本 | 每行一个句子 | 无标签数据 |
为什么NLP任务常用TSV而非CSV?
CSV文件中的文本如果包含逗号会导致解析错误:
"1,这部电影很不错,值得一看"
解析器可能把逗号也当作列分隔符 → 数据错位!
TSV使用不可见的制表符(\t)作为分隔符,
文本中极少出现制表符 → 几乎不会有解析问题。
自检问题:
- 用你自己的话解释"为什么要在训练模型前做EDA"。
- TSV和CSV格式的核心区别是什么?NLP任务为什么偏好TSV?
数据探索性分析(EDA)全景图
EDA是什么?为什么要做?
**EDA(Exploratory Data Analysis,探索性数据分析)**是指在建模之前,通过统计和可视化手段对数据进行多角度检查的过程。
EDA能回答的核心问题:
| 问题 | 不做的后果 |
|---|---|
| 数据量够不够? | 数据太少导致过拟合 |
| 标签分布是否均衡? | 模型学会"猜多数类" |
| 文本长度如何分布? | 截断或填充策略错误 |
| 有多少独特词汇? | 词汇表设置不合理导致OOV过多 |
| 训练集和测试集分布是否一致? | 泛化能力差 |
生活类比:EDA就像你搬家前先看一遍房子——你不想搬完才发现卧室没有窗户、厨房没有水槽。同样,你不想训练3天模型后才发现数据有严重问题。
EDA的标准流程
一个完整的文本数据EDA流程:
第1步:加载数据 + 查看基本信息
└→ pd.read_csv() → df.head() → df.info() → df.describe()
第2步:标签分布分析
└→ 类别均衡吗? → countplot
第3步:文本长度分析
└→ 句子有多长?分布如何? → hist + displot
第4步:标签与长度的关系
└→ 不同标签的文本长度有差异吗? → stripplot/boxplot
第5步:词汇统计
└→ 有多少独特词?训练/测试词表重叠度? → set
第6步:词频分析与词云
└→ 哪些词最常见?正负样本的关键词差异? → Counter + WordCloud
第7步:发现问题 + 制定策略
└→ 根据分析结果调整预处理策略
环境准备与数据加载
在开始分析之前,确保环境就绪:
# ============================================================
# 第0步:导入所有需要的库
# ============================================================
# 数据处理核心库
import pandas as pd # 数据表格操作
import numpy as np # 数值计算
# 可视化库
import matplotlib.pyplot as plt # 基础绑图
import seaborn as sns # 高级统计绑图(基于matplotlib)
# NLP工具
import jieba # 中文分词
import jieba.posseg as pseg # 词性标注
# Python内置工具
from itertools import chain # 扁平化嵌套列表
from collections import Counter # 词频统计
# 词云生成
from wordcloud import WordCloud # 词云可视化
# ============================================================
# 设置全局样式
# ============================================================
# 设置中文字体(避免绑图中的中文显示为方框)
plt.rcParams['font.sans-serif'] = ['SimHei'] # 使用黑体
plt.rcParams['axes.unicode_minus'] = False # 正常显示负号
# 使用fivethirtyeight样式(看起来像专业数据新闻的图表)
plt.style.use('fivethirtyeight')
# ============================================================
# 加载数据
# ============================================================
# 假设数据文件在当前目录的cn_data子目录下
# train.tsv 和 dev.tsv 是制表符分隔的文本分类数据集
# 格式:label\ttext(第一列为标签,第二列为文本)
train_data = pd.read_csv('./cn_data/train.tsv', sep='\t')
# sep='\t' 表示使用制表符作为列分隔符
dev_data = pd.read_csv('./cn_data/dev.tsv', sep='\t')
# ============================================================
# 快速查看数据的基本信息
# ============================================================
print("=" * 50)
print("训练集基本信息:")
print(f"样本数量: {len(train_data)}")
print(f"列名: {train_data.columns.tolist()}")
print(f"\n前5行数据:")
print(train_data.head())
print("\n" + "=" * 50)
print("数据类型和缺失值:")
print(train_data.info())
# info() 会显示每列的数据类型和非空值数量
# 如果某列的非空值数量 < 样本总数 → 存在缺失值!
print("\n" + "=" * 50)
print("验证集基本信息:")
print(f"样本数量: {len(dev_data)}")
# ============================================================
# 数据检查清单
# ============================================================
# 在进入详细分析前,先快速检查以下问题:
# 1. 是否有缺失值?
print(f"\n训练集缺失值统计:")
print(train_data.isnull().sum())
# 如果isnull().sum() > 0,说明有缺失值,需要处理
# 2. 数据量是否合理?
print(f"\n训练/验证集比例: {len(train_data)}/{len(dev_data)} = "
f"{len(train_data)/len(dev_data):.2f}:1")
# 3. 标签是否包含了预期范围内?
print(f"\n训练集标签的唯一值: {sorted(train_data['label'].unique())}")
# 对于二分类情感分析,预期标签为 [0, 1](负面/正面)
自检问题:
- 如果在
df.isnull().sum()中发现某列有50%的缺失值,你会怎么处理?df.info()能告诉你哪些关键信息?
标签与文本长度分析
标签分布分析——你的数据"偏科"了吗?
这是EDA中最先要做的检查。分类任务中,如果某些类别的样本远多于其他类别,模型可能会"偷懒"。
# ============================================================
# 标签分布分析
# ============================================================
# ------ 训练集标签分布 ------
plt.figure(figsize=(10, 5))
# subplot(1,2,1) 表示 1行2列 的第1个子图
plt.subplot(1, 2, 1)
# countplot: 统计每个标签出现的频次
sns.countplot(x='label', data=train_data)
plt.title('训练集 (train) 标签分布')
plt.xlabel('情感标签')
plt.ylabel('样本数量')
# 在柱子上方标注具体数值
for p in plt.gca().patches:
plt.gca().annotate(
f'{int(p.get_height())}',
(p.get_x() + p.get_width() / 2., p.get_height()),
ha='center', va='bottom'
)
# ------ 验证集标签分布 ------
plt.subplot(1, 2, 2)
sns.countplot(x='label', data=dev_data)
plt.title('验证集 (dev) 标签分布')
plt.xlabel('情感标签')
plt.ylabel('样本数量')
for p in plt.gca().patches:
plt.gca().annotate(
f'{int(p.get_height())}',
(p.get_x() + p.get_width() / 2., p.get_height()),
ha='center', va='bottom'
)
plt.tight_layout() # 自动调整子图间距
plt.show()
# ============================================================
# 判断标签是否均衡
# ============================================================
train_label_counts = train_data['label'].value_counts()
dev_label_counts = dev_data['label'].value_counts()
print("训练集标签分布:")
print(f" 正面 (1): {train_label_counts.get(1, 0)} 条 "
f"({train_label_counts.get(1, 0)/len(train_data)*100:.1f}%)")
print(f" 负面 (0): {train_label_counts.get(0, 0)} 条 "
f"({train_label_counts.get(0, 0)/len(train_data)*100:.1f}%)")
# 计算不平衡比率
max_count = train_label_counts.max()
min_count = train_label_counts.min()
imbalance_ratio = max_count / min_count
print(f" 不平衡比率: {imbalance_ratio:.2f}:1")
if imbalance_ratio > 3:
print(f" ⚠ 警告:数据集严重不平衡(比率 > 3:1)")
print(f" 建议:使用类别权重、过采样或欠采样等方法处理")
elif imbalance_ratio > 1.5:
print(f" ⚡ 注意:数据集轻微不平衡")
print(f" 建议:关注F1-Score而非Accuracy作为评估指标")
else:
print(f" ✓ 标签分布基本均衡")
标签不均衡的应对策略
当发现标签不均衡时,以下是常见的解决方法:
策略 说明 复杂度
──────── ──────────── ────
类别权重 (Class Weight) 训练时给少数类更高的损失权重 低
过采样 (Oversampling) 复制少数类样本 低
SMOTE 合成新的少数类样本 中
欠采样 (Undersampling) 丢弃部分多数类样本 低
数据增强 (Data Augmentation) 回译、同义词替换生成更多少数类 中
选择建议:
· 轻微不均衡(< 3:1)→ 类别权重即可
· 中度不均衡(3:1 ~ 10:1)→ 过采样或SMOTE
· 严重不均衡(> 10:1)→ 综合多种方法
文本长度分析——句子有多长?
深度学习模型需要固定长度的输入,因此了解数据集中文本的长度分布至关重要。
# ============================================================
# 文本长度分析
# ============================================================
# ------ 第1步:计算每个文本的长度 ------
# 思路:对每行的sentence列调用len()函数
# map + lambda 是pandas中逐行处理的标准做法
train_data['sentence_length'] = list(map(
lambda text: len(text), # 匿名函数:返回文本长度
train_data['sentence'] # 要处理的列
))
# 查看添加的新列
print("前5条数据的文本长度:")
print(train_data[['sentence', 'sentence_length']].head())
# ------ 第2步:长度统计摘要 ------
print("\n文本长度统计:")
print(f" 最小长度: {train_data['sentence_length'].min()}")
print(f" 最大长度: {train_data['sentence_length'].max()}")
print(f" 平均长度: {train_data['sentence_length'].mean():.1f}")
print(f" 中位数长度: {train_data['sentence_length'].median():.0f}")
print(f" 标准差: {train_data['sentence_length'].std():.1f}")
# ------ 第3步:分位数分析(决定截断长度的关键!)-----
print("\n分位数分析:")
percentiles = [0.25, 0.50, 0.75, 0.80, 0.85, 0.90, 0.95, 0.99]
for p in percentiles:
value = train_data['sentence_length'].quantile(p)
print(f" {p*100:5.0f}% 分位数: {value:.0f}")
# 分位数解读示例:
# 90%分位数为250 → 90%的文本长度不超过250个字符
# 这意味着如果用250作为截断长度,只会"砍掉"10%的文本
# 这是一个在信息保留和计算效率之间的平衡点
# ------ 第4步:长度分布直方图 ------
plt.figure(figsize=(12, 5))
# 子图1:countplot(每个长度一个柱子)
plt.subplot(1, 2, 1)
sns.countplot(x='sentence_length', data=train_data)
plt.title('文本长度分布(countplot)')
plt.xlabel('文本长度(字符数)')
plt.ylabel('样本数量')
plt.xticks([]) # 如果长度种类太多,隐藏x轴标签以免重叠
# 子图2:displot(密度分布图,更平滑地展示分布形态)
plt.subplot(1, 2, 2)
sns.displot(x='sentence_length', data=train_data, kind='kde')
# kind='kde' 表示核密度估计,展示的是概率密度的连续曲线
plt.title('文本长度密度分布')
plt.xlabel('文本长度(字符数)')
plt.yticks([]) # 密度值对我们意义不大,隐藏y轴
plt.tight_layout()
plt.show()
# ------ 第5步:关键发现和建议 ------
print("\n" + "=" * 50)
print("长度分析的关键发现:")
p90 = train_data['sentence_length'].quantile(0.90)
p95 = train_data['sentence_length'].quantile(0.95)
print(f"1. 90%分位数: {p90:.0f} 字符 → 建议截断长度为 {int(p90)}")
print(f"2. 95%分位数: {p95:.0f} 字符 → 如果GPU内存充足,可设为 {int(p95)}")
print(f"3. 极长文本(> {p95:.0f} 字符): "
f"{(train_data['sentence_length'] > p95).sum()} 条")
print(f"4. 极短文本(< 5 字符): "
f"{(train_data['sentence_length'] < 5).sum()} 条")
# 注意:截断长度的选择是一个 trade-off:
# · 长度越大 → 保留信息越多 → 模型越大,训练越慢
# · 长度越小 → 模型越小,训练越快 → 可能丢失关键信息
文本长度分布的三种典型形态
形态A:正态分布(理想的) 形态B:长尾分布(常见) 形态C:双峰分布(需要注意)
╱╲ ╱╲ ╱╲ ╱╲
╱ ╲ ╱ ╲ ╱ ╲╱ ╲
╱ ╲ ╱ ╲__ ╱ ╲
╱ ╲ ╱ ╲___ ╱ ╲
────────── ─────────────── ──────────────────
大部分在中间值 聚集在短文本 两个群体(短文+长文)
对策:取均值附近 对策:取90-95%分位数 对策:分别处理或取较长者
标签与文本长度的关系分析
有时候,不同标签的文本长度有系统性差异——这是需要了解的重要模式。
# ============================================================
# 标签-长度关系分析
# ============================================================
# ------ 方法1:散点图(stripplot)-------
# stripplot: 将数据点沿x轴随机抖动显示,避免重叠
plt.figure(figsize=(10, 6))
sns.stripplot(
y='sentence_length', # y轴:文本长度
x='label', # x轴:标签类别
data=train_data,
jitter=True, # 允许点随机抖动(避免完全重叠)
alpha=0.5 # 透明度0.5(重叠的地方颜色更深)
)
plt.title('不同标签的文本长度分布(散点图)')
plt.xlabel('情感标签(0=负面, 1=正面)')
plt.ylabel('文本长度(字符数)')
plt.show()
# ------ 方法2:箱线图(boxplot)-------
# boxplot: 展示分位数、中位数和异常值
plt.figure(figsize=(10, 6))
sns.boxplot(
y='sentence_length',
x='label',
data=train_data
)
plt.title('不同标签的文本长度箱线图')
plt.xlabel('情感标签(0=负面, 1=正面)')
plt.ylabel('文本长度(字符数)')
plt.show()
# 箱线图解读:
# ┌───── 上边缘(非异常值的最大值)
# │ ┌── 上四分位数 (Q3, 75%分位数)
# │ │
# ├──┤─ 中位数 (Median, Q2, 50%分位数)
# │ │
# │ └── 下四分位数 (Q1, 25%分位数)
# └───── 下边缘(非异常值的最小值)
# ● 异常值(超出1.5倍IQR范围的点)
# ------ 按标签统计长度 ------
print("\n不同标签的文本长度统计:")
for label in sorted(train_data['label'].unique()):
subset = train_data[train_data['label'] == label]
print(f"\n 标签 {label} ({len(subset)} 条):")
print(f" 平均长度: {subset['sentence_length'].mean():.1f}")
print(f" 中位数长度: {subset['sentence_length'].median():.0f}")
print(f" 标准差: {subset['sentence_length'].std():.1f}")
# 如果某个标签的文本系统性更长或更短,
# 需要在截断策略中考虑这一差异。
自检问题:
- 为什么通常取90%或95%分位数作为截断长度,而不是最大长度?
- 如果正负样本的文本长度有显著差异(如负面评论普遍更长),这可能暗示什么?对模型有什么影响?
词汇统计与词云可视化
词汇量统计——你的数据"词汇丰富"吗?
# ============================================================
# 词汇量统计
# ============================================================
from itertools import chain
import jieba
# ------ 训练集词汇统计 ------
# 核心思路:
# 1. 对每条文本进行jieba分词
# 2. 将所有文本的分词结果展开为一个"扁平"列表
# 3. 用set去重,得到所有不重复的词
# 代码解读:
# map(lambda x: jieba.lcut(x), train_data['sentence'])
# → 对每一行文本进行jieba分词,得到一个"嵌套列表"
# → 例如:[['我','爱','北京'], ['天安门','上','太阳升']]
#
# chain(* ...)
# → 将嵌套列表"展开"为一维列表
# → ['我','爱','北京','天安门','上','太阳升']
#
# set(...)
# → 去重,只保留不重复的词
train_vocab = set(chain(*map(
lambda text: jieba.lcut(text), # 分词函数
train_data['sentence'] # 对每行文本应用
)))
print(f"训练集词汇量(不重复的词数): {len(train_vocab)}")
print(f"训练集总词数(含重复): {sum(len(jieba.lcut(t)) for t in train_data['sentence'])}")
# ------ 验证集词汇统计 ------
dev_vocab = set(chain(*map(
lambda text: jieba.lcut(text),
dev_data['sentence']
)))
print(f"验证集词汇量: {len(dev_vocab)}")
# ------ 训练集和验证集词汇重叠分析 ------
# 这是非常重要的一步!
# 如果验证集中有很多训练集没见过的词 → OOV(Out-of-Vocabulary)问题严重
overlap = train_vocab & dev_vocab # 交集:两边都有的词
only_train = train_vocab - dev_vocab # 差集:只在训练集中的词
only_dev = dev_vocab - train_vocab # 差集:只在验证集中的词
print(f"\n词汇重叠分析:")
print(f" 两集合共有词: {len(overlap)} "
f"({len(overlap)/len(dev_vocab)*100:.1f}% 的验证集词汇)")
print(f" 仅训练集有: {len(only_train)}")
print(f" 仅验证集有 (OOV词): {len(only_dev)} "
f"({len(only_dev)/len(dev_vocab)*100:.1f}% 的验证集词汇)")
# OOV比率解读:
# < 5% : 很好,词汇覆盖率高
# 5-15% : 一般,可能有少量未知词
# 15-30% : 差,考虑使用子词方法(如FastText、BPE)
# > 30% : 严重问题,训练集和测试集可能来自不同的分布
if len(only_dev) / len(dev_vocab) > 0.15:
print(f" ⚠ 验证集OOV比例较高,建议:")
print(f" 1. 使用子词分词方法(BPE, SentencePiece等)")
print(f" 2. 增加训练数据量")
print(f" 3. 使用FastText,利用子词信息处理OOV词")
# ------ 查看一些OOV词的样子 ------
print(f"\n验证集中有但训练集中没有的词(前20个):")
print(list(only_dev)[:20])
# 这些词将成为模型的"盲点"——模型从未见过它们
词频统计——最常出现的是什么词?
# ============================================================
# 词频统计
# ============================================================
from collections import Counter
# 对所有训练文本分词并计数
all_words = chain(*map(lambda text: jieba.lcut(text), train_data['sentence']))
word_counter = Counter(all_words)
print("训练集中最常见的30个词:")
for word, count in word_counter.most_common(30):
print(f" {word:10s} : {count:6d}")
# 观察最重要的发现:
# - 停用词(的、了、是、在...)通常占据前几位
# - 这些词对情感分类可能帮助不大
# - 在特征工程中可能需要去除停用词
# 对验证集也做统计,对比高频词是否一致
all_dev_words = chain(*map(lambda text: jieba.lcut(text), dev_data['sentence']))
dev_word_counter = Counter(all_dev_words)
print("\n训练集 vs 验证集 前20高频词对比:")
train_top20 = set([w for w, _ in word_counter.most_common(20)])
dev_top20 = set([w for w, _ in dev_word_counter.most_common(20)])
print(f" Top20重叠: {len(train_top20 & dev_top20)}/20")
print(f" 验证集Top20中不在训练集Top20的词: {dev_top20 - train_top20}")
词云可视化——用"图"看词频
词云是一种直观展示词频的可视化方式——词在图中越大,表示它出现的频率越高。
# ============================================================
# 词云可视化
# ============================================================
from wordcloud import WordCloud
import jieba.posseg as pseg
# ------ 辅助函数:提取特定词性的词 ------
def get_words_by_pos(text, target_pos=None):
"""
从文本中提取指定词性的词
参数:
text (str): 待处理的中文文本
target_pos (str or list): 要提取的词性标签
常用词性:'a'(形容词), 'v'(动词), 'n'(名词), 'd'(副词)
如果为None,则返回所有词
返回:
list: 符合条件的词语列表
"""
result = []
for word, flag in pseg.lcut(text):
if target_pos is None or flag in target_pos:
result.append(word)
return result
# 示例:
# get_words_by_pos("这个产品非常好用", target_pos='a') → ['好']
# get_words_by_pos("这个产品非常好用", target_pos=['a', 'd']) → ['非常', '好']
# ------ 辅助函数:生成并显示词云 ------
def get_word_cloud(keywords_list, title="词云图"):
"""
根据词语列表生成并显示词云
参数:
keywords_list (list): 词语字符串列表
title (str): 词云图标题
"""
# 创建WordCloud对象
wordcloud = WordCloud(
font_path="./SimHei.ttf", # 中文字体路径(必须指定!否则中文会显示为方框)
max_words=100, # 最多显示100个词
background_color='white', # 背景色:白色
width=800, # 图片宽度(像素)
height=400, # 图片高度(像素)
collocations=False, # 不统计搭配词组(对中文分词场景设为False)
random_state=42 # 随机种子(保证每次生成的词云相同)
)
# 将词语列表拼接为一个字符串(词云内部会按空格分词)
keywords_string = " ".join(keywords_list)
# 生成词云
wordcloud.generate(keywords_string)
# 显示词云
plt.figure(figsize=(12, 6))
plt.imshow(wordcloud, interpolation="bilinear")
plt.title(title, fontsize=16)
plt.axis('off') # 隐藏坐标轴
plt.tight_layout()
plt.show()
return wordcloud
# ============================================================
# 按正负样本分别生成词云
# 这可以帮我们了解:正面评价和负面评价各在说什么?
# ============================================================
# ------ 正面样本的词云(label=1)------
print("正面样本形容词词云:")
positive_texts = train_data[train_data['label'] == 1]['sentence']
# 提取所有正面样本中的形容词
positive_adjectives = chain(*map(
lambda text: get_words_by_pos(text, target_pos=['a', 'ad']),
positive_texts
))
positive_adjectives = list(positive_adjectives)
print(f" 正面样本共有 {len(positive_adjectives)} 个形容词")
if positive_adjectives:
get_word_cloud(positive_adjectives, title="正面评价 - 形容词词云")
# ------ 负面样本的词云(label=0)------
print("\n负面样本形容词词云:")
negative_texts = train_data[train_data['label'] == 0]['sentence']
negative_adjectives = chain(*map(
lambda text: get_words_by_pos(text, target_pos=['a', 'ad']),
negative_texts
))
negative_adjectives = list(negative_adjectives)
print(f" 负面样本共有 {len(negative_adjectives)} 个形容词")
if negative_adjectives:
get_word_cloud(negative_adjectives, title="负面评价 - 形容词词云")
# ============================================================
# 进阶:正负面词频对比分析
# ============================================================
# 找出在正面和负面中差异最大的词——这些是关键的情感指示词
from collections import Counter
# 统计正面样本的词频
pos_counter = Counter(positive_adjectives)
# 统计负面样本的词频
neg_counter = Counter(negative_adjectives)
# 计算每个词在正面样本中的比例
# 比例 > 0.7 → 该词是强正面指示词
# 比例 < 0.3 → 该词是强负面指示词
print("\n情感指示词分析(形容词):")
print("强正面指示词(P(正面|词) > 0.8):")
for word in pos_counter.most_common(30):
w = word[0]
pos_count = pos_counter[w]
neg_count = neg_counter.get(w, 0)
total = pos_count + neg_count
if total >= 5: # 至少出现5次才有统计意义
pos_ratio = pos_count / total
if pos_ratio > 0.8:
print(f" {w:10s}: 正面{pos_count}次, 负面{neg_count}次, "
f"正面比例={pos_ratio:.2f}")
print("\n强负面指示词(P(正面|词) < 0.2):")
for word in neg_counter.most_common(30):
w = word[0]
neg_count = neg_counter[w]
pos_count = pos_counter.get(w, 0)
total = pos_count + neg_count
if total >= 5:
pos_ratio = pos_count / total if total > 0 else 0
if pos_ratio < 0.2:
print(f" {w:10s}: 正面{pos_count}次, 负面{neg_count}次, "
f"正面比例={pos_ratio:.2f}")
词云解读指南
词云中的词大小 = 出现频率
解读方法:
1. 最大最显眼的词 → 出现最多的词(可能是停用词)
2. 如果停用词占据了主导 → 考虑去停用词后再生成词云
3. 正负面词云的差异 → 不同情感的关键表达
例如:
正面词云中"好"、"不错"、"推荐"很大
负面词云中"差"、"垃圾"、"失望"很大
→ 合理,模型的判断可能与人类直觉一致
如果词云杂乱无章,没有任何情感相关词
→ 数据可能有问题,或者情感信息使用更微妙的方式表达
自检问题:
- 为什么要在训练模型前统计训练集和验证集的词汇重叠情况?
- 词云中最大的词一定是最重要的词吗?为什么?
n-gram特征——捕捉局部语序信息
为什么需要n-gram?
回顾一下,在第二章中我们学习了One-Hot编码的各种问题。其中一个问题是:完全忽略了词的顺序。
"我爱你" 和 "你爱我"
如果用词袋模型(只看词出现与否),两者完全相同——
都是 {"我":1, "爱":1, "你":1}
无法区分语序的不同
n-gram通过将连续n个词的组合作为特征,部分弥补了这个缺陷。
什么是n-gram?
n-gram定义:连续n个词(或字符)组成的序列。
例句:"我 爱 北京 天安门"
1-gram (unigram): ["我"], ["爱"], ["北京"], ["天安门"]
→ 就是单个词(退化为词袋模型)
2-gram (bigram): ["我 爱"], ["爱 北京"], ["北京 天安门"]
→ 连续2个词的组合
3-gram (trigram): ["我 爱 北京"], ["爱 北京 天安门"]
→ 连续3个词的组合
生活类比:n-gram就像拍照取景。
1-gram = 给每个人单独拍照
2-gram = 给每对相邻的人拍合照
3-gram = 给每三个相邻的人拍合照
不同n值捕捉不同范围的人际关系。
n-gram的公式
对于序列 [ w 1 , w 2 , . . . , w n ] [w_1, w_2, ..., w_n] [w1,w2,...,wn],其bigram(2-gram)为:
bigrams ( [ w 1 , . . . , w n ] ) = { ( w 1 , w 2 ) , ( w 2 , w 3 ) , . . . , ( w n − 1 , w n ) } \text{bigrams}([w_1, ..., w_n]) = \{(w_1, w_2), (w_2, w_3), ..., (w_{n-1}, w_n)\} bigrams([w1,...,wn])={(w1,w2),(w2,w3),...,(wn−1,wn)}
注意:n-gram的数量 = 序列长度 - n + 1
序列长度=6, 2-gram → 6-2+1 = 5个bigram
序列长度=10, 3-gram → 10-3+1 = 8个trigram
n-gram的Python实现
# ============================================================
# n-gram实现的完整分析
# ============================================================
def demo_ngram():
"""
演示如何使用Python内置函数实现n-gram
n-gram的核心操作:
将序列"滑动窗口"式地切分为固定长度的子序列
"""
# 示例输入:一个词ID序列
input_list = [1, 3, 2, 1, 5, 3]
ngram_range = 2 # 生成bigram
print(f"输入序列: {input_list}")
print(f"n-gram范围: {ngram_range}")
print()
# ------ 核心实现(逐步分析)------
# 第1步:生成n个"偏移"版本的序列
# [input_list[i:] for i in range(ngram_range)] 的作用:
# 当 ngram_range=2 时,生成:
# [input_list[0:]] = [1, 3, 2, 1, 5, 3] ← 原序列
# [input_list[1:]] = [3, 2, 1, 5, 3] ← 向后偏移1位
slices = [input_list[i:] for i in range(ngram_range)]
print("偏移后的序列:")
for i, s in enumerate(slices):
print(f" slices[{i}]: {s} (偏移 {i} 位)")
# 第2步:使用zip(*slices)配对对齐的元素
# zip([1,3,2,1,5,3], [3,2,1,5,3])
# → (1,3), (3,2), (2,1), (1,5), (5,3)
# zip自动以最短序列为准,所以最后一个元素"3"会被丢弃
paired = zip(*slices)
print("\nzip配对结果:")
paired_list = list(paired)
for p in paired_list:
print(f" {p}")
# 第3步:用set去重
res = set(paired_list)
print(f"\n去重后的bigram: {res}")
# 输出示例:{(3, 2), (1, 3), (2, 1), (1, 5), (5, 3)}
demo_ngram()
# ============================================================
# 通用n-gram生成函数
# ============================================================
def generate_ngrams(sequence, n):
"""
从序列生成n-gram
参数:
sequence (list): 输入序列
n (int): n-gram的n值
返回:
list of tuple: n-gram元组列表
示例:
generate_ngrams([1, 2, 3, 4], 2) → [(1,2), (2,3), (3,4)]
generate_ngrams([1, 2, 3, 4], 3) → [(1,2,3), (2,3,4)]
"""
# 输入验证
if n > len(sequence):
raise ValueError(f"n ({n}) 不能大于序列长度 ({len(sequence)})")
if n < 1:
raise ValueError(f"n ({n}) 必须 >= 1")
# 核心实现:利用zip + 列表推导
# [sequence[i:] for i in range(n)] 生成n个偏移序列
# zip(*) 将偏移序列配对
ngrams = list(zip(*[sequence[i:] for i in range(n)]))
return ngrams
# 测试
print("\n通用n-gram生成函数测试:")
seq = [1, 3, 2, 1, 5, 3]
print(f"序列: {seq}")
print(f"bigram (n=2): {generate_ngrams(seq, 2)}")
print(f"trigram (n=3): {generate_ngrams(seq, 3)}")
# 输出:
# bigram (n=2): [(1, 3), (3, 2), (2, 1), (1, 5), (5, 3)]
# trigram (n=3): [(1, 3, 2), (3, 2, 1), (2, 1, 5), (1, 5, 3)]
# ============================================================
# 文本的n-gram特征增强
# ============================================================
def add_ngram_features(word_ids, n_range=(2, 3), vocab_offset=1000):
"""
将原始词ID序列扩展为包含n-gram特征的序列
参数:
word_ids (list): 原始词ID序列,如 [1, 34, 21]
n_range (tuple): n-gram范围,如 (2, 3) 表示同时添加2-gram和3-gram
vocab_offset (int): n-gram ID的起始偏移量
(确保n-gram ID与原始词ID不冲突)
返回:
list: 扩展后的ID序列
示例:
add_ngram_features([1, 34, 21], n_range=(2,))
→ [1, 34, 21, 1000, 1001]
其中1000表示(1,34)这个bigram,1001表示(34,21)这个bigram
"""
ngram_id = vocab_offset # 起始ID
enhanced = list(word_ids) # 保留原始词ID
for n in range(n_range[0], n_range[1] + 1):
if n > len(word_ids):
continue
for ngram in generate_ngrams(word_ids, n):
enhanced.append(ngram_id)
ngram_id += 1
return enhanced
# 测试
print("\nn-gram特征增强测试:")
original = [1, 34, 21]
enhanced = add_ngram_features(original, n_range=(2, 2))
print(f"原始词ID: {original}")
print(f"增强后(含bigram): {enhanced}")
# 输出:
# 原始词ID: [1, 34, 21]
# 增强后(含bigram): [1, 34, 21, 1000, 1001]
# ↑原始词↑ ↑bigram特征↑
# ============================================================
# n-gram的应用场景对比
# ============================================================
print("\n" + "=" * 50)
print("n-gram在NLP中的应用场景:")
print()
applications = [
("n=1 (unigram)", "词袋模型",
"文本分类的基线方法,完全忽略词序",
"垃圾邮件检测(只看关键词如'免费'、'中奖')"),
("n=2 (bigram)", "简单短语捕捉",
"能捕捉到'非常_好' vs '不_好'的区别",
"情感分析('not_bad' vs 'bad')"),
("n=3 (trigram)", "常见搭配识别",
"能识别'New_York_City'等固定搭配",
"实体识别、短语抽取"),
("n=3-5", "传统特征增强",
"提升传统机器学习模型的文本分类效果",
"FastText的字符n-gram特征"),
]
for n, name, desc, example in applications:
print(f"[{n:15s}] {name}")
print(f" 描述: {desc}")
print(f" 示例: {example}")
print()
n-gram的优缺点
| 维度 | 优点 | 缺点 |
|---|---|---|
| 建模能力 | 捕捉局部语序信息,弥补词袋模型的不足 | 不能捕捉长距离依赖("我…书"被中间词隔开) |
| 特征数量 | 可控(通过n值和词汇过滤) | n越大→特征越稀疏→维度灾难 |
| 可解释性 | n-gram特征可以被人类理解 | 大量的n-gram组合难以逐一分析 |
| 计算成本 | 计算简单,仅需滑动窗口 | 内存占用随n和词汇量增长 |
自检问题:
- 用自己的话解释
zip(*[input_list[i:] for i in range(2)])是如何生成bigram的。- n=5的5-gram相比n=2的bigram有什么优势和劣势?
序列填充(Padding)——统一输入尺寸
为什么需要Padding?
答案很简单:深度学习模型要求所有输入样本具有相同的形状。
如果你在一个batch中传入两个句子:
句子A: [1, 23, 5, 32, 55, 63, 2, 21, 78, 32, 23, 1] ← 12个词
句子B: [2, 32, 1, 23, 1] ← 5个词
PyTorch/TensorFlow报错:
"RuntimeError: stack expects each tensor to be equal size"
为什么?因为张量(Tensor)必须是规则的长方体,
不能出现"凹凸不平"的情况。
生活类比:
Padding就像把不同长度的木材切成统一尺寸的木条:
没有Padding:
木条A: ████████████████ (16cm)
木条B: ██████ (6cm)
→ 无法整齐地打包成捆
有Padding:
木条A: ████████████████ (保持16cm,截断到10cm)
木条B: ██████░░░░ (补4cm的填充物到10cm)
→ 都是10cm,可以整齐地打包!
其中 ░ 是填充符号(在数值上就是0)
Padding的公式
pad ( x , m a x l e n ) = { x [ : m a x l e n ] if l e n ( x ) ≥ m a x l e n [ 0 , . . . , 0 , x 1 , . . . , x n ] if l e n ( x ) < m a x l e n \text{pad}(x, maxlen) = \begin{cases} x[:maxlen] & \text{if } len(x) \geq maxlen \\ [0, ..., 0, x_1, ..., x_n] & \text{if } len(x) < maxlen \end{cases} pad(x,maxlen)={x[:maxlen][0,...,0,x1,...,xn]if len(x)≥maxlenif len(x)<maxlen
公式解读:
- 如果序列长度 >= maxlen:从开头或结尾截断到maxlen长度
- 如果序列长度 < maxlen:在开头或结尾补0,直到长度等于maxlen
代码实现详解
# ============================================================
# 序列填充(Padding)的完整理解
# ============================================================
from tensorflow.keras.preprocessing import sequence
# ============================================================
# 第1步:准备示例数据
# ============================================================
# 两个"句子",每个数字是一个词的ID
x_train = [
[1, 23, 5, 32, 55, 63, 2, 21, 78, 32, 23, 1], # 句子A: 12个词
[2, 32, 1, 23, 1] # 句子B: 5个词
]
print("原始数据:")
for i, seq in enumerate(x_train):
print(f" 句子{i}: 长度={len(seq)}, 内容={seq}")
# ============================================================
# 第2步:确定截断长度
# ============================================================
# cutlen 的选择应该基于EDA中的分位数分析
# 这里为了演示,设定为10
cutlen = 10
print(f"\n设定截断长度: {cutlen}")
print(f"句子A长度(12) >= {cutlen} → 将被截断")
print(f"句子B长度(5) < {cutlen} → 将被填充")
# ============================================================
# 第3步:执行Padding和Truncating
# ============================================================
# pad_sequences 是Keras提供的序列标准化函数
# 它将不等长的序列列表转换为等长的numpy数组
res = sequence.pad_sequences(
sequences=x_train, # 输入:序列列表
maxlen=cutlen, # 目标长度:所有序列统一为10
padding='post', # padding='post': 在序列末尾补0
# padding='pre': 在序列开头补0
truncating='post', # truncating='post': 从序列末尾截断
# truncating='pre': 从序列开头截断
value=0.0, # 填充值:默认0,也可以用其他值
dtype='int32' # 输出数据类型:默认int32
)
print(f"\n填充后结果(形状: {res.shape}):")
print(res)
# 输出:
# [[ 5 32 55 63 2 21 78 32 23 1] ← 句子A:被从前面截断,保留了后面10个词
# [ 0 0 0 0 0 2 32 1 23 1]] ← 句子B:在开头补了5个0
# ============================================================
# 第4步:理解padding和truncating的组合效果
# ============================================================
# 演示不同的padding/truncating组合
sentence_B = [2, 32, 1, 23, 1] # 长度为5的短句子
print(f"\n原始句子B: {sentence_B} (长度=5)")
print(f"目标长度: {cutlen}")
print()
# 组合1: padding='post', truncating='post'
# 短句:末尾补0;长句:末尾截断
result_1 = sequence.pad_sequences(
[sentence_B], maxlen=cutlen,
padding='post', truncating='post'
)
print(f"padding='post', truncating='post': {result_1[0]}")
# → [2, 32, 1, 23, 1, 0, 0, 0, 0, 0]
# 短句:保留前面,在末尾补0
# 组合2: padding='pre', truncating='post'
# 短句:前面补0;长句:末尾截断
result_2 = sequence.pad_sequences(
[sentence_B], maxlen=cutlen,
padding='pre', truncating='post'
)
print(f"padding='pre', truncating='post': {result_2[0]}")
# → [0, 0, 0, 0, 0, 2, 32, 1, 23, 1]
# 短句:在开头补0,保留后面的词
# 组合3: padding='post', truncating='pre'
# 短句:末尾补0;长句:开头截断
result_3 = sequence.pad_sequences(
[sentence_B], maxlen=cutlen,
padding='post', truncating='pre'
)
print(f"padding='post', truncating='pre': {result_3[0]}")
# → [2, 32, 1, 23, 1, 0, 0, 0, 0, 0]
# 短句:保留前面,在末尾补0(和组合1一样)
# 组合4: padding='pre', truncating='pre'
# 短句:前面补0;长句:开头截断
result_4 = sequence.pad_sequences(
[sentence_B], maxlen=cutlen,
padding='pre', truncating='pre'
)
print(f"padding='pre', truncating='pre': {result_4[0]}")
# → [0, 0, 0, 0, 0, 2, 32, 1, 23, 1]
# 短句:在开头补0,保留后面的词(和组合2一样)
# ============================================================
# 第5步:长句子的截断演示
# ============================================================
sentence_A = [1, 23, 5, 32, 55, 63, 2, 21, 78, 32, 23, 1] # 长度12
print(f"\n原始句子A: {sentence_A} (长度=12)")
print(f"目标长度: {cutlen}")
print()
# truncating='post': 保留前10个词,丢弃末尾
result_A_post = sequence.pad_sequences(
[sentence_A], maxlen=cutlen,
truncating='post', padding='post'
)
print(f"truncating='post' (保留前10,丢末尾):")
print(f" {result_A_post[0]}")
# → [ 1 23 5 32 55 63 2 21 78 32]
# truncating='pre': 保留后10个词,丢弃开头
result_A_pre = sequence.pad_sequences(
[sentence_A], maxlen=cutlen,
truncating='pre', padding='post'
)
print(f"truncating='pre' (保留后10,丢开头):")
print(f" {result_A_pre[0]}")
# → [ 5 32 55 63 2 21 78 32 23 1]
padding和truncating的选择策略
这是一个重要的工程决策,取决于你的任务类型:
选择指南:
padding='post' (末尾填充):
→ 适用于:RNN/LSTM(模型按顺序读取,前面的词更重要)
→ 适用于:大多数NLP任务
→ 特点:真实词汇在前面,填充在后面
padding='pre' (开头填充):
→ 适用于:某些需要对称处理的场景
→ 较少使用,可能导致RNN"看过"补0后再读真实词
truncating='post' (末尾截断):
→ 适用于:文本的开头更重要(如新闻标题、摘要的开头)
→ 特点:保留开头信息,丢弃结尾
truncating='pre' (开头截断):
→ 适用于:文本的结尾更重要(如长文的结论部分)
→ 适用于:聊天对话(最后几条消息最重要)
→ 特点:保留最近的(后面的)信息
Padding参数速查表
| 参数 | 可选值 | 默认值 | 说明 |
|---|---|---|---|
sequences |
list of lists | (必需) | 输入的序列列表 |
maxlen |
int 或 None | None(自动取最大长度) | 目标统一长度 |
dtype |
numpy dtype | ‘int32’ | 输出数组的数据类型 |
padding |
‘pre’ 或 ‘post’ | ‘pre’ | 在序列的前面(pre)还是后面(post)补填充值 |
truncating |
‘pre’ 或 ‘post’ | ‘pre’ | 从序列的前面(pre)还是后面(post)截断 |
value |
float 或 int | 0.0 | 填充值(默认0) |
自检问题:
- 为什么深度学习模型需要所有输入样本具有相同的形状?
- 在处理新闻分类任务时,你选择
truncating='pre'还是truncating='post'?为什么?- 如果一个序列全是0(全是填充符号),会对模型产生什么影响?这个问题如何解决?(提示:masking)
翻译API——跨语言的桥梁
为什么在文本预处理中需要翻译?
翻译在文本预处理中有多种应用场景:
- 数据增强:将中文文本翻译成英文再翻译回中文(回译),生成新的训练样本
- 跨语言迁移:将英文数据集翻译成中文,扩展训练数据
- 多语言NLP:构建支持多语言的NLP系统
- 文本标准化:将非标准表达翻译为标准表达
回译(Back Translation)示例:
原始文本:"这个产品非常好用"
↓ 中→英
英文:"This product is very easy to use"
↓ 英→中
回译:"这款产品非常易于使用"
↓
回译文本与原文本含义相同但表达不同 → 可以作为新的训练样本!
有道翻译API调用详解
# ============================================================
# 有道翻译API调用
# ============================================================
import requests
# ============================================================
# 基础调用示例
# ============================================================
def dm_translate_basic():
"""
最基本的翻译API调用示例
演示中译英和英译中两个方向
"""
url = 'http://fanyi.youdao.com/translate'
print("=" * 50)
print("有道翻译API基础调用")
print("=" * 50)
# ======== 翻译1:中文 → 英文 ========
text1 = '你好世界'
# 构建请求参数
data1 = {
'from': 'zh-CHS', # 源语言代码:zh-CHS = 简体中文
'to': 'en', # 目标语言代码:en = 英语
'i': text1, # i = input,待翻译的文本
'doctype': 'json' # 返回格式:json(结构化数据)
}
# 发送POST请求
# requests.post() 向指定URL发送HTTP POST请求
# params=data 表示将参数以查询字符串放在URL中
response1 = requests.post(url=url, params=data1)
# 解析返回的JSON数据
# .json() 将JSON格式的响应文本转为Python字典
res1 = response1.json()
print(f"\n原文(中→英): {text1}")
print(f"返回结果: {res1}")
# 提取翻译结果
# 有道API返回的结构通常为:
# {
# 'type': 'ZH_CN2EN',
# 'errorCode': 0,
# 'translateResult': [[{'tgt': 'Hello World', 'src': '你好世界'}]]
# }
# tgt = target (翻译结果), src = source (原文)
translated_text = res1['translateResult'][0][0]['tgt']
print(f"翻译结果: {translated_text}")
# ======== 翻译2:英文 → 中文 ========
text2 = 'The price is very cheap'
data2 = {
'from': 'en', # 源语言:英语
'to': 'zh-CHS', # 目标语言:简体中文
'i': text2, # 待翻译文本
'doctype': 'json' # 返回JSON格式
}
response2 = requests.post(url=url, params=data2)
res2 = response2.json()
print(f"\n原文(英→中): {text2}")
translated_text2 = res2['translateResult'][0][0]['tgt']
print(f"翻译结果: {translated_text2}")
# 执行基础示例
dm_translate_basic()
# ============================================================
# 进阶:封装成可复用的函数
# ============================================================
def translate_advanced(text, from_lang='zh-CHS', to_lang='en', timeout=5):
"""
增强版翻译函数,包含错误处理和多种语言支持
参数:
text (str): 待翻译的文本
from_lang (str): 源语言代码
to_lang (str): 目标语言代码
timeout (int): 请求超时时间(秒)
返回:
str: 翻译后的文本,失败时返回None
常用语言代码:
zh-CHS : 简体中文 en : 英语
ja : 日语 ko : 韩语
fr : 法语 de : 德语
es : 西班牙语 ru : 俄语
pt : 葡萄牙语 it : 意大利语
"""
url = 'http://fanyi.youdao.com/translate'
data = {
'from': from_lang,
'to': to_lang,
'i': text,
'doctype': 'json'
}
try:
# 发送请求并设置超时
response = requests.post(
url=url,
params=data,
timeout=timeout
)
# 检查HTTP状态码(200表示成功)
response.raise_for_status()
# 解析JSON
result = response.json()
# 检查有道API的错误码
error_code = result.get('errorCode', -1)
if error_code != 0:
error_messages = {
101: "缺少必填参数",
102: "不支持的语言类型",
103: "翻译文本过长",
108: "应用ID无效",
110: "无相关服务的访问权限",
111: "开发者账号异常",
202: "签名检验失败",
}
error_msg = error_messages.get(error_code, f"未知错误({error_code})")
print(f"[有道API错误] 错误码={error_code}: {error_msg}")
return None
# 提取翻译结果
translated = result['translateResult'][0][0]['tgt']
return translated
except requests.exceptions.Timeout:
print(f"[网络错误] 请求超时(>{timeout}秒),请检查网络连接")
return None
except requests.exceptions.ConnectionError:
print("[网络错误] 无法连接到有道翻译服务器,请检查网络")
return None
except requests.exceptions.HTTPError as e:
print(f"[HTTP错误] {e}")
return None
except (KeyError, IndexError, TypeError) as e:
print(f"[解析错误] 返回数据格式异常: {e}")
print(f"原始返回内容: {response.text[:200]}") # 打印前200字符
return None
except Exception as e:
print(f"[未知错误] {type(e).__name__}: {e}")
return None
# 测试增强版函数
print("\n" + "=" * 50)
print("增强版翻译函数测试")
print("=" * 50)
# 测试1:正常翻译
result = translate_advanced('你好世界')
print(f"\n测试1(中→英): '你好世界' → '{result}'")
# 测试2:英译中
result = translate_advanced('Machine learning is fascinating', from_lang='en', to_lang='zh-CHS')
print(f"测试2(英→中): → '{result}'")
# 测试3:错误语言代码
result = translate_advanced('Hello', from_lang='invalid_lang', to_lang='zh-CHS')
print(f"测试3(错误语言码): → '{result}'")
# ============================================================
# 应用场景:回译数据增强
# ============================================================
def back_translate(text, intermediate_lang='en', source_lang='zh-CHS'):
"""
回译(Back Translation):中文→中间语言→中文
这是一种经典的数据增强技术:
通过翻译的"往返",生成语义相同但表达不同的新文本
参数:
text (str): 原始中文文本
intermediate_lang (str): 中间语言代码(默认英文)
source_lang (str): 源语言代码
返回:
str: 回译后的中文文本,失败时返回None
"""
print(f"原始文本: {text}")
# 第1步:中文 → 英文
en_text = translate_advanced(text, from_lang=source_lang, to_lang=intermediate_lang)
if en_text is None:
return None
print(f"中→英: {en_text}")
# 第2步:英文 → 中文
back_text = translate_advanced(en_text, from_lang=intermediate_lang, to_lang=source_lang)
if back_text is None:
return None
print(f"英→中: {back_text}")
return back_text
# 测试回译
print("\n" + "=" * 50)
print("回译数据增强测试")
print("=" * 50)
test_texts = [
"这家餐厅的菜品非常美味",
"产品质量太差,不建议购买",
"物流速度很快,包装也很用心",
]
for text in test_texts:
print(f"\n{'─' * 40}")
result = back_translate(text)
if result:
print(f"✓ 回译成功,新文本可用于数据增强")
else:
print(f"✗ 回译失败")
翻译API的局限性
使用免费翻译API时需要注意以下限制:
限制 说明
──────────────────────── ─────────────────────────────────
调用频率限制 免费API通常有每分钟/每天的调用次数上限
翻译质量 免费接口的翻译质量可能不稳定
文本长度限制 单次翻译通常限制在几千字符以内
网络依赖 需要稳定的网络连接
隐私风险 文本会发送到第三方服务器
服务不稳定 免费API可能随时变更或停止服务
生产环境建议:
- 使用官方付费API(有道、百度、Google Cloud Translation等)
- 对敏感数据使用本地翻译模型
- 为API调用添加重试和缓存机制
自检问题:
- 回译数据增强的原理是什么?它为什么能生成新的训练样本?
- 使用免费翻译API有哪些风险?在什么场景下应该避免使用?
Python函数式编程工具详解
chain函数——列表展平的利器
在文本预处理中,我们经常需要把嵌套的列表"展平":
原始结构:[[词1, 词2], [词3, 词4], [词5]] → 嵌套列表
目标结构:[词1, 词2, 词3, 词4, 词5] → 一维列表
itertools.chain 就是做这件事的工具。
from itertools import chain
# ============================================================
# chain的基础用法
# ============================================================
# 示例数据
b1 = [1, 2, 3]
b2 = ['x', 'y', 'z']
print("chain的基础用法:")
print(f"b1 = {b1}")
print(f"b2 = {b2}")
# 用法1:连接多个可迭代对象
c1 = chain(b1, b2)
print(f"\nchain(b1, b2) = {list(c1)}")
# 输出:[1, 2, 3, 'x', 'y', 'z']
# 用法2:连接可迭代对象本身(不展开)
a = [(1, 2, 3), ('x', 'y', 'z')]
print(f"\na = {a}")
c2 = chain(a) # 不带星号*:把a当作一个整体
print(f"chain(a) = {list(c2)}")
# 输出:[(1, 2, 3), ('x', 'y', 'z')]
# a被视为一个可迭代对象,不是展开的元素
c3 = chain(*a) # 带星号*:展开a的每个元素
print(f"chain(*a) = {list(c3)}")
# 输出:[1, 2, 3, 'x', 'y', 'z']
# a中的每个元组被展开为独立的可迭代对象
# ============================================================
# chain在NLP中的典型使用
# ============================================================
# 场景:有一个句子列表,每个句子被分词为词列表
sentences = [
['我', '爱', '自然语言'],
['深度', '学习', '很', '有趣'],
['NLP', '改变', '世界']
]
print(f"\n原始句子列表:")
for s in sentences:
print(f" {s}")
# 使用chain展平所有词
all_words = list(chain(*sentences))
print(f"\n展平后的所有词: {all_words}")
# 输出:['我', '爱', '自然语言', '深度', '学习', '很', '有趣', 'NLP', '改变', '世界']
# 结合set去重
unique_words = set(chain(*sentences))
print(f"去重后的词汇: {unique_words}")
print(f"词汇量: {len(unique_words)}")
# ============================================================
# chain + map 的组合用法(最常用于词汇统计)
# ============================================================
# 组合解读:
# 1. map(分词函数, 文本列表) → 嵌套的词列表
# 2. chain(*嵌套列表) → 一维的词列表
# 3. set(一维列表) → 去重后的词汇集合
# 这个组合在NLP中极其常见,理解它很重要!
chain的"星号"(*)详解
* 在Python中称为"解包操作符"(unpacking operator),它把一个可迭代对象"展开"为其元素:
# * 解包的直观理解:
# 没有星号
example = [(1, 2), (3, 4)]
list(chain(example))
# → chain将example视为一个可迭代对象
# → 它迭代产生 (1,2) 和 (3,4)
# → 结果:[(1, 2), (3, 4)]
# 有星号
list(chain(*example))
# → *example 将example展开为 (1,2) 和 (3,4) 两个独立的参数
# → chain((1,2), (3,4))
# → chain分别迭代(1,2)产生1和2,再迭代(3,4)产生3和4
# → 结果:[1, 2, 3, 4]
zip函数——配对连接的桥梁
zip 将多个可迭代对象的对应元素配对:
# ============================================================
# zip的基础用法
# ============================================================
# 示例数据
a = [1, 2, 3]
b = [4, 5, 6, 7] # 注意:b比a长一个元素
print(f"a = {a}")
print(f"b = {b}")
# zip配对
zipobj = zip(a, b)
result = list(zipobj)
print(f"\nzip(a, b) = {result}")
# 输出:[(1, 4), (2, 5), (3, 6)]
# 关键:以较短的为准!b中多余的7被丢弃了
# 如果想让较长的为准(用填充值补全较短的):
from itertools import zip_longest
result_longest = list(zip_longest(a, b, fillvalue=-1))
print(f"zip_longest(a, b, fillvalue=-1) = {result_longest}")
# 输出:[(1, 4), (2, 5), (3, 6), (-1, 7)]
# ============================================================
# zip的巧妙用法:n-gram生成
# ============================================================
# 这是zip最精妙的NLP应用
def explain_zip_ngram():
"""详细解释zip如何生成n-gram"""
seq = [1, 3, 2, 1, 5, 3]
n = 2
print(f"序列: {seq}")
print(f"n = {n}")
print()
# 第1步:生成n个偏移版本
offsets = [seq[i:] for i in range(n)]
print("第1步:生成偏移序列")
for i, off in enumerate(offsets):
print(f" offset {i}: {off}")
# offset 0: [1, 3, 2, 1, 5, 3]
# offset 1: [3, 2, 1, 5, 3] ← 注意比offset 0少1个元素
# 第2步:zip配对
result = list(zip(*offsets))
print(f"\n第2步:zip配对结果: {result}")
# [(1,3), (3,2), (2,1), (1,5), (5,3)]
print("\n为什么最后一个是(5,3)而不是(3,某物)?")
print("因为offset 1只有5个元素,zip以短者为准")
print(f"offset 0长度={len(offsets[0])}, offset 1长度={len(offsets[1])}")
print(f"所以配对出{min(len(offsets[0]), len(offsets[1]))}个元组")
explain_zip_ngram()
# ============================================================
# zip常见应用场景
# ============================================================
# 场景1:并行迭代两个列表
print("\n场景1:并行迭代")
words = ['我', '爱', 'NLP']
tags = ['r', 'v', 'n']
for word, tag in zip(words, tags):
print(f" {word} → {tag}")
# 场景2:创建字典(键值对)
word_to_tag = dict(zip(words, tags))
print(f"\n场景2:创建映射: {word_to_tag}")
# 输出:{'我': 'r', '爱': 'v', 'NLP': 'n'}
# 场景3:矩阵转置
matrix = [[1, 2, 3], [4, 5, 6], [7, 8, 9]]
transposed = list(zip(*matrix))
print(f"\n场景3:矩阵转置")
print(f"原矩阵: {matrix}")
print(f"转置后: {transposed}")
# 输出:[(1, 4, 7), (2, 5, 8), (3, 6, 9)]
# ============================================================
# chain和zip的组合:多序列特征配对
# ============================================================
# 假设我们有多个序列来源的特征
word_ids = [1, 2, 3, 4, 5] # 词ID
pos_tags = [10, 20, 30, 40, 50] # 词性标签ID
# 将词ID和词性标签配对(常用于需要同时输入词和词性的模型)
paired_features = list(zip(word_ids, pos_tags))
print(f"\n词ID + 词性标签配对: {paired_features}")
# 输出:[(1, 10), (2, 20), (3, 30), (4, 40), (5, 50)]
map函数——批量处理的核心
# ============================================================
# map的基础理解
# ============================================================
# map(函数, 可迭代对象)
# → 对可迭代对象中的每个元素应用函数,返回结果的迭代器
# 基础示例:计算每个数字的平方
numbers = [1, 2, 3, 4, 5]
squared = list(map(lambda x: x**2, numbers))
print(f"原始: {numbers}")
print(f"平方: {squared}")
# 输出:[1, 4, 9, 16, 25]
# NLP中的典型使用:批量分词
sentences = ["我爱北京", "深度学习很有趣", "NLP改变世界"]
tokenized = list(map(lambda s: jieba.lcut(s), sentences))
print(f"\n批量分词:")
for orig, tokens in zip(sentences, tokenized):
print(f" '{orig}' → {tokens}")
# 完整组合:chain + map + set 用于词汇统计
all_vocab = set(chain(*map(lambda s: jieba.lcut(s), sentences)))
print(f"\n所有不重复的词: {all_vocab}")
自检问题:
chain(*a)和chain(a)的区别是什么?用你自己的话解释。- 为什么
zip(*offsets)可以用来生成n-gram?画出偏移序列和配对结果的对应关系。
补充提升内容
文本长度截断策略详解
在真实的NLP项目中,选择截断长度是一个重要的工程决策。以下是四种常用策略:
策略一:固定长度(最简单)
# 取所有样本的最大长度
max_length = max(len(seq) for seq in x_train)
# 优点:不丢失任何信息
# 缺点:
# - 如果有一条极长文本(如5000字符),所有样本都要pad到5000
# - 造成计算资源和内存的巨大浪费
# - 适用于:文本长度天然差异不大的场景
策略二:分位数法(最推荐)
# 取90%或95%分位数
lengths = [len(seq) for seq in x_train]
p90_length = int(np.percentile(lengths, 90))
# 优点:平衡信息保留和计算效率
# 缺点:会丢弃10%样本的超长部分(通常不影响)
# 适用于:大多数NLP任务(这也是最常用的方法)
策略三:分桶法(高级)
# 将文本按长度分到不同的"桶"中,每个桶使用不同的截断长度
buckets = {
'short': (0, 50), # 短文本桶,截断长度50
'medium': (50, 150), # 中文本桶,截断长度150
'long': (150, 300) # 长文本桶,截断长度300
}
# 优点:对短文本不浪费计算,对长文本保留足够信息
# 缺点:实现复杂
# 适用于:文本长度差异极大的场景
策略四:动态填充(最高效)
# 每个batch内部填充到该batch的最大长度,而不是全局最大长度
# PyTorch中的实现:使用collate_fn + pad_sequence
# 优点:最大化计算效率
# 缺点:不同batch的形状不同,实现稍复杂
# 适用于:生产环境的大规模训练
数据划分策略
在机器学习中,我们通常将数据分为三个集合:
完整数据集
│
├── 训练集 (Training Set): 60-80%
│ 用途:训练模型参数
│ 模型"看"这些数据来学习
│
├── 验证集 (Validation Set): 10-20%
│ 用途:调优超参数、选择模型、Early Stopping
│ 模型不直接学习这些数据,但用于"考试"来调整策略
│
└── 测试集 (Test Set): 10-20%
用途:最终评估模型性能
只在最后用一次!不能根据测试集结果回去调整模型
生活类比:
训练集 = 平时的作业和练习题(通过做题学习知识)
验证集 = 模拟考试(检验学习效果,调整复习策略)
测试集 = 高考(最终检验,只能用一次)
如果你根据"高考真题"来调整复习策略(用测试集调参),
那你的"高考成绩"就不能反映真实水平了——
因为你已经"见过"考题了。
数据划分的注意事项
1. 类别保持均衡:训练、验证、测试集中的类别比例应大致相同
2. 随机性:划分前先打乱数据(shuffle)
3. 时间性:如果数据有时间顺序(如新闻),按时间划分而非随机划分
4. 数据泄露:确保同一个样本不会同时出现在训练集和测试集中
5. 分层抽样(Stratified Split):按标签比例分层划分
分层抽样示例:
原始数据:正面1000条,负面200条
随机划分可能导致验证集中没有负面样本
分层抽样确保每个集合中正面:负面 = 5:1(保持原比例)
数据泄露——一个危险但容易犯的错误
什么是数据泄露(Data Leakage)?
数据泄露是指:在训练过程中,模型间接"看到"了测试集的信息。
# ❌ 错误示例:数据泄露!
from sklearn.preprocessing import StandardScaler
# 错误:在整个数据集上计算均值和方差
scaler = StandardScaler()
X_scaled = scaler.fit_transform(X_all) # X_all包含测试集!
# 正确做法:
# 1. 只在训练集上fit
scaler = StandardScaler()
X_train_scaled = scaler.fit_transform(X_train)
# 2. 用训练集的参数transform测试集
X_test_scaled = scaler.transform(X_test)
# ❌ 错误示例:在划分前做了特征选择
from sklearn.feature_selection import SelectKBest
selected_features = SelectKBest(k=100).fit_transform(X_all, y_all)
# 然后用selected_features做划分 → 测试集信息泄漏到了特征选择中!
# ✅ 正确做法:先划分数据,再做特征选择
X_train, X_test, y_train, y_test = train_test_split(X, y)
selected = SelectKBest(k=100).fit(X_train, y_train)
X_train_selected = selected.transform(X_train)
X_test_selected = selected.transform(X_test)
数据泄露的后果:
数据泄露 → 模型在测试集上表现"异常好"
→ 误以为模型效果很好
→ 上线后效果崩溃
→ 浪费时间、金钱和信誉
自检问题:
- 为什么不能根据测试集的结果回去调整模型?用一个生活中的类比来解释。
- 如果你想用90%分位数作为截断长度,这个分位数应该只从哪个数据集计算?
本章总结与自测题
文本预处理的完整流程
现在我们有能力从宏观视角审视整个文本预处理流程了:
原始文本
│
├─→ 第1步:数据加载
│ └→ pd.read_csv() / open()
│
├─→ 第2步:EDA(探索性数据分析)
│ ├→ 标签分布 → countplot
│ ├→ 文本长度 → hist + displot
│ ├→ 标签×长度 → stripplot
│ ├→ 词汇统计 → chain + map + set
│ └→ 词云 → WordCloud
│
├─→ 第3步:文本清洗与分词
│ ├→ 去除噪声(HTML标签、特殊符号)
│ ├→ 分词 → jieba
│ ├→ 词性标注 → jieba.posseg
│ └→ 去停用词(可选)
│
├─→ 第4步:构建词汇表与向量化
│ ├→ Tokenizer → 词 → ID 映射
│ ├→ 选择词向量方法(One-Hot / Word2Vec / Embedding)
│ └→ n-gram特征增强(可选)
│
├─→ 第5步:序列统一化
│ ├→ 确定截断长度(90%分位数)
│ └→ Padding / Truncating → pad_sequences
│
└─→ 第6步:数据划分
└→ 训练集 / 验证集 / 测试集
核心概念回顾
| 概念 | 一句话总结 | 关键工具/方法 |
|---|---|---|
| EDA | 建模前用图表"看懂"数据 | seaborn, matplotlib, wordcloud |
| 标签分布 | 检查类别是否均衡 | countplot |
| 文本长度分析 | 为截断长度提供依据 | hist, displot, 分位数 |
| 词汇统计 | 了解词汇量和OOV情况 | chain + map + set |
| 词云 | 直观展示高频词 | WordCloud |
| n-gram | 连续n个词的组合特征 | zip + 列表推导 |
| Padding | 统一所有序列的长度 | pad_sequences |
| 数据划分 | 训练/验证/测试集的合理划分 | train_test_split |
本章自测题
基础题
-
名词解释:用自己的话解释以下概念:
- EDA(探索性数据分析)
- n-gram
- Padding
- 数据泄露(Data Leakage)
-
代码填空:补全以下代码:
from itertools import chain texts = ["我爱北京", "天安门上太阳升"] # 分词并统计不重复词汇 vocab = set(chain(*map( lambda x: _____.____(x), # 对每行文本分词 texts ))) print(len(vocab)) -
工具选择:
- 你想查看数据集中正面和负面评论各有多少条,应该用什么图?
- 你想展示不同类别评论文本长度的中位数和分位数差异,应该用什么图?
进阶题
-
案例分析:你发现训练集中正面评论占80%,负面评论占20%。请回答:
- 这对模型训练有什么影响?
- 至少提出两种解决方案。
- 应该用什么评估指标而不是Accuracy?
-
设计决策:给定一个中文新闻分类数据集:
- 文本长度范围:10 ~ 5000 字符
- 90%分位数:800 字符
- 训练集10000条,测试集2000条
- 你在设计Padding策略时,会如何选择
maxlen、padding和truncating参数?说明理由。
-
综合应用:你拿到一个情感分析数据集(train.tsv,格式为
label\ttext)。请你写出从加载数据到输出可用于模型训练的向量的完整Python代码框架(包含EDA、分词、词汇表构建、Padding等)。
思考题
-
EDA的价值:有些人认为"与其花时间做EDA,不如直接让深度学习模型自己发现数据中的模式。"你同意这个观点吗?用实际的例子说明EDA在什么时候能避免严重错误。
-
Padding策略对比:比较
padding='pre'和padding='post'在LSTM情感分析任务中的差异。为什么padding='post'通常更适合?在什么特殊情况下padding='pre'可能更好?
学习建议:
- 本章的代码示例都设计了完整的上下文,建议复制到Jupyter Notebook中逐行运行
- 用你自己的数据集(哪怕只是几条微博评论)走一遍完整的EDA+预处理流程
- 重点关注EDA中可能发现的问题:类别不均衡、OOV比例过高、文本长度异常值
- 至此文本预处理三章完成,你应该能独立完成从原始文本到模型输入的完整预处理流程
- 下一阶段建议:学习具体的NLP模型(RNN/LSTM → Transformer → BERT),把预处理好的数据实际跑起来!
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐
所有评论(0)