文本预处理(下)

适用读者:已阅读前两章(NLP概念、文本预处理-上)的零基础初学者。本章将学习如何"读懂数据"(探索性分析)和"改造数据"(特征工程),这是模型训练前的最后两步准备。

学习目标

  1. 掌握数据探索性分析(EDA)的核心方法——用图表"看清"你的数据
  2. 理解n-gram特征的原理和构建方法
  3. 掌握序列填充(Padding)——为什么深度学习要求"统一尺寸"
  4. 学会调用翻译API进行数据增强或跨语言处理
  5. 理解数据划分策略和函数式编程工具(chain、zip)的使用

预计阅读时间:90-120分钟(包含动手练习和思考题时间)

环境准备:确保已安装pandas、seaborn、matplotlib、wordcloud、jieba等库。


目录

  1. 零、热身:从"看数据"到"懂数据"
  2. 一、数据探索性分析(EDA)全景图
  3. 二、标签与文本长度分析
  4. 三、词汇统计与词云可视化
  5. 四、n-gram特征——捕捉局部语序信息
  6. 五、序列填充——统一输入尺寸
  7. 六、翻译API——跨语言的桥梁
  8. 七、Python函数式编程工具详解
  9. 八、补充提升内容
  10. 九、本章总结与自测题

热身:从"看数据"到"懂数据"

一个真实的教训

假设有这样一个场景:

小明花了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)作为分隔符,
文本中极少出现制表符 → 几乎不会有解析问题。

自检问题

  1. 用你自己的话解释"为什么要在训练模型前做EDA"。
  2. 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](负面/正面)

自检问题

  1. 如果在df.isnull().sum()中发现某列有50%的缺失值,你会怎么处理?
  2. 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}")

# 如果某个标签的文本系统性更长或更短,
# 需要在截断策略中考虑这一差异。

自检问题

  1. 为什么通常取90%或95%分位数作为截断长度,而不是最大长度?
  2. 如果正负样本的文本长度有显著差异(如负面评论普遍更长),这可能暗示什么?对模型有什么影响?

词汇统计与词云可视化

词汇量统计——你的数据"词汇丰富"吗?

# ============================================================
# 词汇量统计
# ============================================================

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. 正负面词云的差异 → 不同情感的关键表达

例如:
  正面词云中"好"、"不错"、"推荐"很大
  负面词云中"差"、"垃圾"、"失望"很大
  → 合理,模型的判断可能与人类直觉一致

  如果词云杂乱无章,没有任何情感相关词
  → 数据可能有问题,或者情感信息使用更微妙的方式表达

自检问题

  1. 为什么要在训练模型前统计训练集和验证集的词汇重叠情况?
  2. 词云中最大的词一定是最重要的词吗?为什么?

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),...,(wn1,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和词汇量增长

自检问题

  1. 用自己的话解释zip(*[input_list[i:] for i in range(2)])是如何生成bigram的。
  2. 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)

自检问题

  1. 为什么深度学习模型需要所有输入样本具有相同的形状?
  2. 在处理新闻分类任务时,你选择truncating='pre'还是truncating='post'?为什么?
  3. 如果一个序列全是0(全是填充符号),会对模型产生什么影响?这个问题如何解决?(提示:masking)

翻译API——跨语言的桥梁

为什么在文本预处理中需要翻译?

翻译在文本预处理中有多种应用场景:

  1. 数据增强:将中文文本翻译成英文再翻译回中文(回译),生成新的训练样本
  2. 跨语言迁移:将英文数据集翻译成中文,扩展训练数据
  3. 多语言NLP:构建支持多语言的NLP系统
  4. 文本标准化:将非标准表达翻译为标准表达

回译(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调用添加重试和缓存机制

自检问题

  1. 回译数据增强的原理是什么?它为什么能生成新的训练样本?
  2. 使用免费翻译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}")

自检问题

  1. chain(*a)chain(a)的区别是什么?用你自己的话解释。
  2. 为什么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)

数据泄露的后果

数据泄露 → 模型在测试集上表现"异常好"
        → 误以为模型效果很好
        → 上线后效果崩溃
        → 浪费时间、金钱和信誉

自检问题

  1. 为什么不能根据测试集的结果回去调整模型?用一个生活中的类比来解释。
  2. 如果你想用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

本章自测题

基础题

  1. 名词解释:用自己的话解释以下概念:

    • EDA(探索性数据分析)
    • n-gram
    • Padding
    • 数据泄露(Data Leakage)
  2. 代码填空:补全以下代码:

    from itertools import chain
    
    texts = ["我爱北京", "天安门上太阳升"]
    
    # 分词并统计不重复词汇
    vocab = set(chain(*map(
        lambda x: _____.____(x),  # 对每行文本分词
        texts
    )))
    print(len(vocab))
    
  3. 工具选择

    • 你想查看数据集中正面和负面评论各有多少条,应该用什么图?
    • 你想展示不同类别评论文本长度的中位数和分位数差异,应该用什么图?

进阶题

  1. 案例分析:你发现训练集中正面评论占80%,负面评论占20%。请回答:

    • 这对模型训练有什么影响?
    • 至少提出两种解决方案。
    • 应该用什么评估指标而不是Accuracy?
  2. 设计决策:给定一个中文新闻分类数据集:

    • 文本长度范围:10 ~ 5000 字符
    • 90%分位数:800 字符
    • 训练集10000条,测试集2000条
    • 你在设计Padding策略时,会如何选择maxlenpaddingtruncating参数?说明理由。
  3. 综合应用:你拿到一个情感分析数据集(train.tsv,格式为label\ttext)。请你写出从加载数据到输出可用于模型训练的向量的完整Python代码框架(包含EDA、分词、词汇表构建、Padding等)。

思考题

  1. EDA的价值:有些人认为"与其花时间做EDA,不如直接让深度学习模型自己发现数据中的模式。"你同意这个观点吗?用实际的例子说明EDA在什么时候能避免严重错误。

  2. Padding策略对比:比较padding='pre'padding='post'在LSTM情感分析任务中的差异。为什么padding='post'通常更适合?在什么特殊情况下padding='pre'可能更好?


学习建议

  1. 本章的代码示例都设计了完整的上下文,建议复制到Jupyter Notebook中逐行运行
  2. 用你自己的数据集(哪怕只是几条微博评论)走一遍完整的EDA+预处理流程
  3. 重点关注EDA中可能发现的问题:类别不均衡、OOV比例过高、文本长度异常值
  4. 至此文本预处理三章完成,你应该能独立完成从原始文本到模型输入的完整预处理流程
  5. 下一阶段建议:学习具体的NLP模型(RNN/LSTM → Transformer → BERT),把预处理好的数据实际跑起来!
Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐