文本预处理:从数据到特征

一份「数据 → 特征 → 增强」的文本预处理施工手册,上半篇用五个统计动作把语料的毛病看出来(标签均衡、长度分布、语料质量),下半篇把原始文本变成模型能吃的等长数值张量(n-gram、截断补齐),外加回译增强和三个扩展专题(TF+Cosine / SBERT / FastText Subword)。


一、这份材料解决什么问题

day01《自然语言处理入门》回答的是”NLP 是什么、这条技术路线长什么样”;day02 回答的是紧接着的第一个工程问题:原始文本怎么才能喂进模型。

这件事在两个完全不同的技术路线里都是第一步,但做法不同:

路线 预处理要产出什么 本材料的对应章节
传统机器学习(TF-IDF + 随机森林) 分词后的词序列 → 稀疏数值特征 第 1、2 章(数据分析 + 特征处理)
深度学习(FastText / BERT / LLM) 等长 token id 序列 / 子词序列 第 2.2 节(长度规范)、第 7 章(Subword)

材料开篇给出的定位非常直接:文本数据分析的作用是「帮助理解语料,快速检查出语料可能存在的问题,指导模型训练过程中超参数的选择」。它要解决的其实是四类问题:

  1. 数据有没有毛病,标签均衡吗?长度分布正常吗?语料里有没有和标签矛盾的内容?
  2. 超参数怎么定,max_length 取多少?这是从句子长度分布里”读”出来的,不是拍出来的。
  3. 特征够不够用,词袋丢词序,”喜欢深度学习”和”讨厌深度学习”会被算得很像,靠 n-gram 补。
  4. 样本够不够,数据量小就做回译增强。

为什么这一遍必须过:这条链路上每一个数字都会被追问。PPT 里出现的 15.6 万词、12162 词表、48/80/70/65/39 这些长度,是”你真的看过数据”的唯一证据;而”停用词表把「不」删掉了”这种坑,是评审者用来区分”跑通过”和”背过”的经典问题。


二、核心结论

  1. 预处理的第一步不是清洗,是”看见”数据。五个动作:标签数量分布 → 句子长度分布 → 正负样本长度散点 → 词频统计 → 高频形容词词云。前两个定超参,后三个查质量。
  2. 中文和英文的分词根本不是同一个问题。英文靠空格天然切好;中文没有词间空格,字符序列 c_1c_2\cdots c_n 存在指数级的切分方案,所以分词是一个歧义消解任务。jieba 的三种模式就是三种取舍:精确模式(默认,建模用)、全模式(只求召回,弃)、搜索引擎模式(长词再切,索引用)。
  3. max_length 由句子长度分布决定,不是最大值。取”覆盖绝大多数样本”的长度,长截短补;Keras pad_sequences 的默认是 padding='pre' / truncating='pre',RNN 场景官方建议改 post。
  4. n-gram 是给传统机器学习补词序的最小手段:材料里”我 喜欢 深度学习” vs “我 讨厌 深度学习”的 TF+Cos 相似度,用 unigram 是 0.75,加了 2-gram 降到 0.57。材料同时明确了边界:现代深度学习 NLP 流程几乎不用 n-gram 特征,那是 BERT 的活。
  5. 停用词是双刃剑。它的收益是减维度、降噪声(IDF 只能压低权重、删不掉维度);代价是删错词会让语义翻车,情感分类里「不」「没」「未」一旦被误删,「不满意」就变成「满意」。
  6. 回译增强最多连翻 3 次。中文→韩文→日语→英文→中文这种多跳能降低重复率,但材料写得很清楚:更多次数会产生语义失真,短文本单次回译的重复率可能高到”并不能有效增大样本”。

三、知识地图

按材料的原始章节层级整理。材料的”学习目标”只写”掌握几种常见方法”,没有逐条标注权重,下表的 ★掌握 / 理解 / 了解 是我按它是否进入第 8 章高频题、第 9 章作业、以及是否被反复用于代码推断的。

day02 文本预处理
├── 1 文本数据分析                                            ★掌握(第 8 章第 1 题、第 1 章全部代码)
│   ├── 1.1 作用与方法:标签数量分布 / 句子长度分布 / 词频统计与词云
│   ├── 1.2 数据集说明:中文酒店评论 train.tsv(2960,2) + dev.tsv(1000,2),二分类 0/1
│   │        ├── 标签数量分布        sns.countplot —— 查均衡,二分类 ACC 基线 50%、正负 1:1
│   │        ├── 句子长度分布        sns.histplot(bins=50, kde=True) —— 定 max_length
│   │        ├── 正负样本长度散点    sns.stripplot —— 定位异常点做人工审查
│   │        ├── 词频统计            jieba.lcut + chain + Counter —— 词表大小
│   │        └── 高频形容词词云      pseg.lcut 取 flag=="a" + WordCloud —— 查标签矛盾
│   └── 1.3 小结
│
├── 2 文本特征处理                                            ★掌握(第 8 章第 2、3 题)
│   ├── 2.1 n-gram 特征(1/2/3-gram;滑坡窗口 + zip 实现)      ★掌握
│   ├── 2.2 文本长度规范(截断 + PAD 补齐;python 手写 / tf.sequence)★掌握
│   └── 2.3 小结
│
├── 3 文本数据增强                                           ★掌握(第 8 章第 5 题)
│   ├── 3.1 回译数据增强法(deep_translator / translate)        ★掌握
│   │        ├── 优势:操作简便、新语料质量高
│   │        ├── 问题:短文本重复率高
│   │        └── 解法:连续多语言翻译(≤3 次)
│   └── 3.2 小结
│
├── 4 jieba 词性对照表                                       理解(查表用,不必背)
│   └── a/ad/ag/an/n/nr/ns/nt/nz/v/vn/… + hanlp 对照表(NR/NT/LC/PN/…)
│
├── 5 扩展-TF 方法计算句子相似度                              理解(第 8 章第 4 题的对比题)
│   ├── 5.2 Unigram TF → cos = 0.75
│   ├── 5.3 Unigram + 2-gram TF → cos ≈ 0.57
│   └── 5.4 代码:CountVectorizer / TfidfVectorizer + ngram_range=(1,1)/(1,2)/(1,3)
│
├── 6 扩展-SBERT 句向量计算相似度                             理解
│   ├── 6.2 核心思想:BERT 上下文 → Mean Pooling → 句向量 → L2 归一化 → 余弦
│   ├── 6.3 公式:s = (1/n)Σhᵢ ;ŝ = s/‖s‖
│   └── 6.4 实测:all-MiniLM-L6-v2,384 维,相似度 0.8185
│
├── 7 扩展-FastText 的 Subword 建模                           ★掌握(与项目路线直接相关)
│   └── v_w = Σ z_g(字符 n-gram,常用 n=3~6)→ 缓解 OOV
│
├── 8 常见高频题(6 组)                                      ★掌握
│   └── 分析指标 / 长度规范 / n-gram 作用 / TF-Cos vs SBERT / 回译风险 / 数据泄漏
│
└── 9 代码作业(2 题)                                        ★掌握
    ├── fasttext 预训练词向量:get_word_vector / get_nearest_neighbors
    └── nn.Embedding 手工词表 + 平均句向量

材料本身没写、但”文本预处理”这个题目必然要考的四块(本报告第四节的 4.2~4.4、4.7 补齐):分词的模式与自定词典、归一化(大小写/全半角/繁简/标点)、停用词表的取舍、词表与 OOV 的词元设计。这四块我用标注区分开:材料正文有的用”材料原话”引用,通用工程知识明确标出是补充。


四、原理详解

4.1 文本数据分析:先”看见”数据,再谈清洗

本节所有代码与数字都来自材料第 1 章(真实跑过的输出)。

4.1.1 标签数量分布:查均衡

材料原话:”在深度学习模型评估中,一般使用 ACC 作为评估指标,通常将二分类任务的 ACC 基线设在 50% 左右,需要正负样本比例在 1:1 左右。” 这句话是这一节的灵魂,标签不均衡时,ACC 会失效(90% 正样本的语料,全猜正就有 90% 准确率),所以训练前必须先看分布。

import pandas as pd
import seaborn as sns
import matplotlib.pyplot as plt

train_data = pd.read_csv(r"./data/train.tsv", sep='\t')   # 酒店评论带表头,默认 header=0
print(train_data.head())          # 列:sentence, label

# sns.countplot:绘制离散值的频次直方图
sns.countplot(x="label", data=train_data, hue="label", legend=False)
plt.title("训练集标签数量分布"); plt.show()

材料实测:train.tsv 形状 (2960, 2),dev.tsv 形状 (1000, 2);标签是 0(消极)/ 1(积极)。

4.1.2 句子长度分布:max_length 的来源

train_data["length"] = train_data["sentence"].apply(lambda x: len(x))
# 等价写法:train_data['length'] = list(map(lambda x: len(x), train_data['sentence']))

# sns.histplot:绘制连续值的区间分布;bins=柱子数,kde=是否显示核密度曲线
sns.histplot(train_data["length"], bins=50, kde=True)
plt.title("训练集句子长度分布"); plt.show()

材料原文的关键判断:”模型要求输入为固定长度的张量,句子长度分布可以指导之后的句子截断补齐为合理长度”。实测前 5 条训练样本的 length 是 48 / 80 / 70 / 65 / 39,长度差异一倍以上,这就是必须做长度规范的原因。

这里有个容易被忽略的细节:len(x) 算的是字符数,不是词数。中文语料里两者差别不大(几十倍以内),但如果下游按词表索引序列长度算,就该改成 len(jieba.lcut(x))。用哪个口径,要和 max_length 的单位一致。

4.1.3 正负样本长度散点分布:定位异常点

# sns.stripplot:绘制离散标签上的连续值散点分布
sns.stripplot(x="label", y="length", data=train_data, hue="label", legend=False)
plt.title("训练集正负样本的句子长度散点分布"); plt.show()

材料的用途说明:”通过查看正负样本长度散点图,可以有效定位异常点,进行人工审查。” 比如某个标签下突然出现一批超长文本,很可能是抓取时把多条评论拼在一起了。

4.1.4 词频统计:词表有多大

import jieba
from itertools import chain
from collections import Counter

# 1) 每句分词 2) chain 把 list of list 拍平成一个大列表
train_words = train_data["sentence"].apply(lambda x: jieba.lcut(x))
train_all_words = list(chain(*train_words.tolist()))

print(f"训练集所有词的数量:{len(train_all_words)}")     # 156952
train_words_count = Counter(train_all_words)
print(f"训练集词表大小:{len(train_words_count)}")       # 12162

材料实测数字(背下来,这是”看过数据”的证据):

数据集 总词数(token) 去重词表大小(type)
训练集 train.tsv 156952 12162
测试集 dev.tsv 52422 6857

注意 len(train_all_words) 和 len(train_words_count) 的差别正是 NLP 里 token 与 type 的区别:156952 个 token 只落在 12162 个不同的词上,平均每词出现约 13 次,这个比值同时告诉你”词表不大、可以用 TF-IDF”,以及”长尾里一定有大量只出现 1 次的词”。

4.1.5 高频形容词词云:查标签与语料是否矛盾

import jieba.posseg as pseg
from wordcloud import WordCloud

def get_adj(words_list):
    results = []
    for word, flag in pseg.lcut(words_list):   # 分词 + 词性标注
        if flag == "a":                        # a = 形容词
            results.append(word)
    return results

def show_cloud(words_list, title="训练集正样本的高频形容词词云"):
    word_cloud = WordCloud(font_path=r"./data/SimHei.ttf", max_words=100,
                           background_color="white")
    word_cloud.generate(" ".join(words_list))   # 词云要求空格分隔的字符串
    plt.figure(figsize=(8, 8)); plt.imshow(word_cloud)
    plt.axis("off"); plt.title(title); plt.show()

train_a_list_1 = train_data[train_data['label'] == 1]["sentence"].apply(get_adj)
train_a_list_1 = list(chain(*train_a_list_1.tolist()))
show_cloud(train_a_list_1, title="训练集正样本的高频形容词词云")

材料对结论的表述很克制:”上图中的正样本多数是褒义词,而负样本多数是贬义词,基本符合要求,但是负样本词云中也存在『方便』,可以进一步审查。” 这就是词云的真实价值,它不是可视化装饰,而是一次廉价的质量抽检:如果好评里高频出现”不方便””不是很好”,说明语料标注有问题。


4.2 分词:中文预处理真正的起点(补充,工程必备)

英文 "I love deep learning" 用空格切就是 4 个 token;中文「我喜欢深度学习」没有空格,是 7 个字。字粒度语义太散(BERT 中文确实用字,但它靠海量预训练补回来),词粒度才有人能理解的语义单元。所以中文必须先分词。

分词的核心困难是切分歧义和未登录词(OOV)。”南京市长江大桥”可以切成「南京市/长江大桥」或「南京/市长/江大桥」。工业级分词(jieba)的路线是:

  1. 基于前缀词典把句子构造成一张 DAG(有向无环图),每个字出发的所有可能成词路径都放进去;
  2. 用动态规划在 DAG 上找一条概率最大的路径(等价于最大概率的切分);
  3. 对词典里根本没出现的连续汉字片段,用 HMM + Viterbi 做序列标注切分。

我用 jieba 0.42.1 实测了材料里没有的三种模式(这是最容易追问的点):

import jieba
s = "我来到北京清华大学"
print('/'.join(jieba.lcut(s)))                    # 精确模式(默认)
print('/'.join(jieba.lcut(s, cut_all=True)))      # 全模式
print('/'.join(jieba.lcut_for_search(s)))         # 搜索引擎模式

真实输出:

精确:     我/来到/北京/清华大学
全模式:   我/来到/北京/清华/清华大学/华大/大学
搜索:     我/来到/北京/清华/华大/大学/清华大学

模式 行为 输出特点 该用在哪
精确模式 cut(s) / lcut(s) 最可能的唯一路径 词数少、无重叠 一切建模场景(默认,就用这个)
全模式 cut_all=True 输出所有可能成词 有重叠、有噪声(清华/清华大学/华大) 只用在对召回敏感的检索前置,建模绝不用
搜索引擎模式 lcut_for_search() 在精确模式基础上,把长词再切出子词,长词放最后 粒度更细且覆盖长词 倒排索引 / 关键词匹配

工程上还必须会这几件事(同样实测过):

# 1) 自定义词典:项目专有词默认会被切碎
print(jieba.lcut("大模型项目用BERT做新闻标题分类"))
# ['大', '模型', '项目', '用', 'BERT', '做', '新闻标题', '分类']   ← "大模型"没被识别

jieba.add_word("大模型", freq=1000, tag="nz")     # 运行时加词
print(jieba.lcut("大模型项目用BERT做新闻标题分类"))
# ['大模型', '项目', '用', 'BERT', '做', '新闻标题', '分类']

jieba.load_userdict("userdict.txt")               # 从文件批量加载,每行:词 [词频] [词性]

  • jieba.cut() 返回生成器,jieba.lcut() 返回 list,迭代一次就空的坑基本都来自这里。
  • 词性标注用 jieba.posseg.lcut(s),返回 pair(词, 词性);材料第 4 章的对照表就是查这个 flag 用的(a 形容词、n 名词、nr 人名、ns 地名、nt 机构团体名、nz 其他专名、v 动词、vn 名动词……)。
  • 分词器必须训练/推理完全一致:jieba 的版本、自定义词典、停用词表,任何一样变了,向量空间就悄悄错位,指标会掉且不报错。

4.3 归一化:大小写 / 全半角 / 繁简 / 标点与数字(补充,工程必备)

iPhone 和 iphone、NLP 和 NLP、臺灣 和 台湾,在人眼里是同一个东西,在词表里是三个互不相干的 token。归一化的目标就是让”本来相同的字符串真的相同”,减少词表里的伪维度。

(1) 大小写:英文统一小写 text.lower();但专有名词有代价,Apple(苹果公司)和 apple(苹果)会被合并。分类/检索任务通常照合,NER 任务要慎重。

(2) 全角 ↔ 半角:全角字符的 Unicode 码位有规律,U+FF01–U+FF5E 与 U+0021–U+007E 一一对应,差值是 0xFEE0(实测:ord('A')-ord('A') == 0xFEE0,ord('1')-ord('1') == 0xFEE0);全角空格是独立的 U+3000。

def to_halfwidth(s: str) -> str:
    out = []
    for ch in s:
        code = ord(ch)
        if code == 0x3000:                 # 全角空格 → 半角空格
            out.append(' ')
        elif 0xFF01 <= code <= 0xFF5E:     # 全角 ASCII → 半角
            out.append(chr(code - 0xFEE0))
        else:
            out.append(ch)
    return ''.join(out)

print(repr(to_halfwidth('NLP 2026,模型A!')))
# 'NLP 2026,模型A!'      ← 全角字母/数字/逗号/空格都归一半角,汉字(模型)不动

(3) 繁简转换:简体训练、繁体测试是中文项目的经典事故。工具是 OpenCC(pip install opencc,OpenCC('t2s').convert(text) 做繁转简,还有 t2s/s2t/t2tw 等配置)。jieba 官方也声明支持繁体分词,但对多音字/异体字归一最好还是显式转一次。

(4) 标点、特殊符号与数字:材料第 1 章的数据分析里,标点其实是被当成词参与词频统计的(我用 jieba 实测过,「我 觉得 这个 酒店 位置 很 好,但是 房间 没有 窗户」里的「,」是独立 token)。所以要么把它们放进停用词表,要么用正则统一处理:

import re

raw = '<p>限时8折!iPhone15 Pro 只要¥7999!!! 详情 https://t.cn/abc123 @好友 #双十一#</p>\n'
t = re.sub(r'<[^>]+>', '', raw)                  # 去 HTML 标签
t = re.sub(r'https?://\S+|www\.\S+', '', t)      # 去 URL
t = re.sub(r'@[\w\u4e00-\u9fa5-]+', '', t)        # 去 @提及
t = re.sub(r'#([^#]+)#', r'\1', t)                # 去话题井号、保留内容
t = re.sub(r'\s+', ' ', t).strip()                # 空白归一
print(repr(t))          # '限时8折!iPhone15 Pro 只要¥7999!!! 详情 双十一'

t = re.sub(r'\d+', '<NUM>', t)                    # 数字归一为占位符
t = re.sub(r'[^\u4e00-\u9fa5a-zA-Z0-9<>\s]', '', t)   # 只保留中文/英文/数字/占位符/空格
print(repr(t))          # '限时<NUM>折iPhone<NUM> Pro 只要<NUM> 详情 双十一'

数字到底删还是留?材料没有给结论,工程上的判断标准是”数字对标签有没有区分力”:

  • 新闻分类/情感分析:具体数值往往是噪声,映射成一个 <NUM> 占位符比直接删更好,它保留了”这里有个数字”这个模式(如”降 8 折””3 天到货”)。
  • 商品价格、股票、时间序列任务:数字是核心特征,要单独做数值化处理,绝不能删。

至于 '限时8折' 在 [^\u4e00-\u9fa5…] 过滤后变成 '限时<NUM>折',中文和数字之间没有空格,这一步只去掉了标点,不会切断词。要注意这个正则不会插入分隔符,所以它不能替代分词。


4.4 停用词:为什么去,代价是什么(补充,工程必备)

为什么要去:两重收益。

  1. 降维:中文里”的/了/是/在/和”这类词能占到 token 总量的很大比例,删掉它们能让词表显著变小,TF-IDF 矩阵更稀疏、训练更快。
  2. 降噪:这些词几乎所有文档都有,IDF 本来就会把它们的权重压得很低,但注意,IDF 只是”压低权重”,词还占着矩阵的一列;stop_words 参数是直接把整列删掉。这也是项目里要额外准备一份停用词表、而不是纯靠 IDF 的理由。

代价:删错词 = 语义翻转。最典型的是否定词「不/没/未/无/别」。比如「这个酒店不干净」去掉「不」就是「这个酒店干净」,正负标签直接反了。所以有一条硬纪律:

停用词表是任务相关的,不存在”通用最优值”。情感分析/评论分类必须人工过一遍停用词表,把否定词、程度副词(很/太/非常/有点)从表里踢出去。

可用代码(实测输出):

import re, jieba

stop = set('的 了 是 在 我 你 他 和 就 都 而 及 与 着 或 一个 没有 我们 这 那 很'.split())
sent = '我觉得这个酒店的位置很好,但是房间没有窗户'

words = [w for w in jieba.lcut(sent)
         if w.strip()                     # 去空白 token
         and w not in stop                # 过停用词表
         and not re.fullmatch(r'[\W_]+', w)]   # 去过纯标点 token
print(words)
# ['觉得', '这个', '酒店', '位置', '好', '但是', '房间', '窗户']

注意这里 好 留下了、很 被停用词表吃掉了,如果下游是情感分析,这个停用词表就是错的(”很好”和”好”是两种强度)。词表的每一行都要有任务视角的解释,这也是第九节作业里”人工审查”的意义。


4.5 文本长度规范:截断与补齐(材料 2.2 节)

一个 batch 里句子长度是 48、80、70、65、39……而模型要的是形状固定的张量 (B, L)。所以必须把 L 统一:长的砍,短的补。

材料原话,”一般模型的输入需要等序列长度的矩阵,需要统一每条文本序列长度 sequence_length,根据句子长度分布得到多数文本的合理长度,对长文本进行截断,对短文本进行补齐(一般使用填充符号 PAD)。” 补的元素是索引 0,也就是 <PAD>。

材料给了两种实现,输出完全一致(下面是材料实测输出,我复现过)。

# ---- 方法一:python 手写 ----
MAX_LENGTH = 10

def demo01(sentences, max_length=MAX_LENGTH):
    results = []
    for sentence in sentences:
        if len(sentence) > max_length:
            sentence = sentence[:max_length]              # 长 → 从尾部截断
        else:
            padding_length = max_length - len(sentence)
            sentence = sentence + [0] * padding_length    # 短 → 尾部补 0
        results.append(sentence)
    return results

# ---- 方法二:tensorflow ----
from tensorflow.keras.preprocessing import sequence

def demo02(sentences, max_length=MAX_LENGTH):
    return sequence.pad_sequences(sentences, maxlen=max_length,
                                  padding="post", truncating="post")

sentences = [[0,1,2,3,4,5,6,7,8,9,10], [4,5,6,7,8,9,10], [9,10],
             [0,1,2,3,4,5,6,7,8,9,1,2,3,4,5,6,7,8,9,10]]
print(demo01(sentences))
# [[0,1,2,3,4,5,6,7,8,9], [4,5,6,7,8,9,10,0,0,0], [9,10,0,0,0,0,0,0,0,0], [0,1,2,3,4,5,6,7,8,9]]
print(demo02(sentences))     # 返回 numpy 数组,数值与上面逐行相同

四个参数的含义(材料注释 + 我的补充):

参数 取值 材料里的写法 说明
maxlen 整数 MAX_LENGTH=10 目标长度,来自 4.1.2 的长度分布
padding 'pre' / 'post' 显式传 "post" 补齐位置。Keras 默认是 'pre'(前面补)
truncating 'pre' / 'post' 显式传 "post" 截断位置。Keras 默认也是 'pre'(从头砍,保留句尾)
value 数值 默认 0 PAD 的填充值

两个必须记住的点:

  1. 材料代码显式写了 padding="post", truncating="post",但 Keras 的默认值是 'pre'/'pre'。不要把”代码里写的”记成”API 默认的”。truncating='pre' 意味着长句保留句尾,对新闻标题要慎用(标题信息量最大的词通常在开头)。
  2. TensorFlow 官方文档在讲 masking & padding 时明确建议:用 RNN 层时应使用 post padding,这样才能用上 cuDNN 的加速实现。所以 LSTM/GRU/Transformer 场景下 post 是更常见的选择。

max_length 怎么定:看长度分布直方图,取一个能覆盖绝大多数样本(经验上是 95%~99% 分位)的长度。取最大值 = 大量无意义的 PAD(浪费算力,还会让 attention 被 padding 稀释);取太小 = 关键信息被截掉。


4.6 n-gram 特征(材料 2.1 节)

词袋模型把句子当”词的集合”,”我 喜欢 深度学习”和”我 讨厌 深度学习”只差一个词,所以被算得很像。n-gram 就是把相邻词的组合也当成一个新特征加进去,让词序重新有了一点信息。

机制与示例(材料原表格):

项目 说明
定义 连续 n 个 token(词或字)组成的新特征,用于捕捉局部上下文信息,n 通常取 2 或 3
常见类型 1-gram(unigram)单词;2-gram(bigram)连续两词;3-gram(trigram)连续三词
应用场景 文本分类(情感分析、垃圾邮件识别)、搜索与推荐、传统机器学习 NLP 的特征工程;现代 NLP 流程几乎不用 n-gram 特征
使用方式 2-gram:在 1-gram 基础上额外加入;3-gram:在 1-gram 基础上同时加入 2-gram 和 3-gram

句子「我 喜欢 自然 语言 处理」的展开:

1-gram:我, 喜欢, 自然, 语言, 处理
2-gram:我喜欢, 喜欢自然, 自然语言, 语言处理
3-gram:我喜欢自然, 喜欢自然语言, 自然语言处理

材料的索引化例子讲得很清楚:分词列表 ["是谁", "敲动", "我心"] 对应索引 [1, 34, 21],把相邻词也编成特征(假设 1000 代表”是谁+敲动”、1001 代表”敲动+我心”),列表就变成 [1, 34, 21, 1000, 1001]。

用滑动窗口实现的通用函数(材料代码,实测输出一致):

ngram_n = 3

def create_ngram_features(input_list, ngram_n=2):
    sliced_list = [input_list[i:] for i in range(ngram_n)]  # 错位切片
    return set(zip(*sliced_list))                            # zip 组合成元组集合

print(create_ngram_features([1, 3, 2, 1, 5, 3], 2))
# {(1, 3), (3, 2), (2, 1), (1, 5), (5, 3)}          —— 5 个 2-gram
print(create_ngram_features([1, 3, 2, 1, 5, 3], 3))
# {(1, 3, 2), (3, 2, 1), (2, 1, 5), (1, 5, 3)}      —— 4 个 3-gram

注意这里用的是 set:同一组合在句子里出现多次会被去重(这也是为什么材料例子里 (1,3,2) 只出现一次,尽管序列里有两个 1 和两个 3)。真正做特征时要换成 Counter 保留频次。

材料对时代边界的表述(常被问):

流程阶段 传统机器学习 NLP 现代深度学习 NLP
第一步 文本 文本
第二步 分词 Tokenizer(分词器)
第三步 特征工程(TF-IDF + n-gram) 预训练语言模型(BERT / GPT / Transformer)
第四步 机器学习模型(LR / SVM / Naive Bayes) 下游任务微调
第五步 分类 / 预测 分类 / 预测

一句话记法:n-gram 是 Transformer 出现之前用来补词序的权宜之计;有了 self-attention,模型自己就能学”喜欢”和”讨厌”之后跟什么。


4.7 词表与 OOV(材料第 7 章 + 第 9 章作业)

模型不认识字,只认识整数。所以要有一步「token → id」的映射,这份映射就是词表(vocabulary)。问题是:训练集见过的词,测试时几乎必然出现没见过的词(OOV, Out-Of-Vocabulary)。词表必须预留位置给”未知”和”填充”。

手工建词表的路线是”先把两个特殊符号占位,再按出现顺序给词编号”:

train = ["酒店 位置 很好", "房间 没有 窗户"]
vocab = {"<PAD>": 0, "<UNK>": 1}          # 0 留给 padding,1 留给未知词
for s in train:
    for w in s.split():
        if w not in vocab:
            vocab[w] = len(vocab)
print(vocab)      # {'<PAD>':0, '<UNK>':1, '酒店':2, '位置':3, '很好':4, '房间':5, '没有':6, '窗户':7}

test = "酒店 隔音 很差".split()
print([vocab.get(w, vocab["<UNK>"]) for w in test])   # [2, 1, 1]

四个特殊 token 的职责(材料作业里只用到了前两个):

token 作用 出现位置
<PAD> 补齐到 max_length 的填充位,不参与损失计算 4.5 节的长度规范
<UNK> 词表外的词统一映射到这里 OOV 兜底
<CLS> / <SEP> BERT 的句子首标记与分隔标记 材料 day12 的 BERT 部分

OOV 怎么缓解,这就是材料第 7 章 FastText Subword 的价值。FastText 不只学”整词”,还学字符 n-gram(子词),常用 n=3\sim6,词向量由子词向量聚合:

\mathbf{v}_w = \sum_{g \in G(w)} \mathbf{z}_g

其中 G(w) 是词 w 的子词集合,\mathbf{z}_g 是子词 g 的向量。材料举的例子是英文词 where(边界符号写成 <where>):3-gram 子词包括 <wh, whe, her, ere, re>;即使测试时遇到没见过的 wheres,也能从它的子词(whe, her, res)组合出可用向量。材料的三条原因总结是:缓解 OOV、提升低频词质量、适合形态变化丰富的语言。一句话收尾:Word2Vec 学”整词”,FastText 学”整词 + 子词”,所以真实场景更稳健。

我用手工 nn.Embedding 的路线把”词表 → 向量”补齐(材料第 9 章作业 2 的写法):nn.Embedding(num_embeddings=len(vocab), embedding_dim=100) 建表,句子 ids = [vocab[t] for t in tokens if t in vocab],取 vecs.mean(dim=0) 就是平均句向量。关键坑:if t in vocab 过滤掉的 OOV 词是静默丢弃的,如果一句话的 token 全被丢光,mean(dim=0) 会得到一个 0 向量,线下不会报错,线上就是一个随机预测。

材料作业 1 里 fasttext.load_model('./model/fil9.bin') + get_word_vector(word) + get_nearest_neighbors(word, k=5) 的实测输出值得注意:查询”深度学习”得到的近邻是 mcgreevey / triggs / gilford / cushing / mcvey(相似度 0.63~0.66),词向量维度 100。这些近邻显然是英文人名,说明 fil9.bin 是一个英文语料(fil9 ≈ 维基百科 fil9 语料)训练出来的英文词向量模型,用它查中文词得到的向量没有语义意义。这是”预训练词向量必须和你的语料/语言匹配”的活教材,被追问时可以直接当例子讲。


4.8 数据增强:回译(材料第 3 章)

翻译过去再翻回来,句子意思不变、说法变了,这就是一条同标签的新样本。

机制(材料原话):”回译数据增强是效果较好的方法,一般基于 google、有道等翻译接口,将文本数据翻译成另外一种语言(一般选择小语种),再翻译回原语言,即可得到与原语料同标签的新语料。”

优势是”操作简便,获得新语料质量高”;问题是”在短文本回译过程中,新语料与原语料可能存在很高的重复率,并不能有效增大样本”。解法是连续多语言翻译,如 中文→韩文→日语→英文→中文,并且给出了经验上限:最多只采用 3 次连续翻译,更多次数将产生语义失真。

from deep_translator import GoogleTranslator    # pip install deep_translator
from translate import Translator                # pip install translate(国内可用)

def translate_text(text, target_language):
    return GoogleTranslator(source='auto', target=target_language).translate(text)

def translate_text2(text, target_language):
    return Translator(to_lang=target_language).translate(text)

if __name__ == '__main__':
    text = "曾经有一段真挚的爱情放在我的面前,可惜我当时在考英语四级"
    r1 = translate_text(text, 'ko')            # 中文 → 韩文
    print(f"翻译结果:{r1}")
    r2 = translate_text(r1, 'zh-CN')           # 韩文 → 中文
    print(f"翻译结果:{r2}")

材料实测输出(很有意思,值得记下来当例子):

翻译结果:한때 내 앞에는 진정한 사랑이 있었지만 안타깝게도 그 당시 나는 CET-4 시험을 치르고 있었습니다.
翻译结果:曾经有一份真爱在我的面前,但不幸的是,当时我正在参加大学英语四级考试。
翻译结果:한때 제 앞에 진심어린 사랑이 있었지만, 안타깝게도 저는 영어 레벨 4 시험을 치르고 있었습니다.
翻译结果:我曾经有一个真诚的爱在我面前,但不幸的是,我参加了英语四级考试。

对比原句和回译句就能看出回译的两个特征:语义保住了(爱情→真爱、英语四级→大学英语四级考试),表述变了(”放在我的面前”→”在我的面前”,”我当时在考”→”我正在参加”)。这就是数据增强要的。同时也能看出为什么次数不能多:每次翻译都在做”同义改写”,3 次之后原文的用词风格已经面目全非,标签之外的噪声就盖过信号了。


4.9 词性标注与对照表(材料第 4 章)

材料第 4 章给了 jieba 词性对照表(约 50 项)和 hanlp 词性对照表。对预处理最常打交道的几条:

标签 含义 标签 含义
a 形容词 n 名词
ad / an 副形词 / 名形词 nr / ns / nt 人名 / 地名 / 机构团体名
d 副词 nz 其他专名
m / mq 数词 / 数量词 v / vn 动词 / 名动词
p 介词 u / uj / ul / uz 助词 / 的 / 了 / 着
c 连词 x 非语素词(标点常落在这里)

实测一句话:

import jieba.posseg as pseg
print(pseg.lcut("小明硕士毕业于中国科学院计算所,后在日本京都大学深造"))
# [('小明','nr'), ('硕士','n'), ('毕业','n'), ('于','p'), ('中国科学院','nt'), ('计算所','n'),
#  (',','x'), ('后在','t'), ('日本京都大学','nt'), ('深造','v')]

预处理里它的两个用途:

  1. 按词性筛词,材料 4.1.5 就是靠 flag == "a" 挑形容词做词云;同理可以”只保留名词+动词”来抽关键词。
  2. 按词性过滤停用词,把 d(副词)、u(助词)、p(介词)、c(连词)整类视作停用词,比维护一张词表更”自动”,但风险和 4.4 一样(不是副词,一刀切会把它删掉)。

hanlp 的标签体系是另一套(NR 专有名词、NT 时间名词、PN 代词、M 单位词、VV 动词、AD 副词、PU 标点、FW 外国词语……),换工具时标签体系要跟着换,不能拿 jieba 的 n 去比 hanlp 的 NN。


4.10 句子相似度的两条路线:TF+Cosine vs SBERT(材料第 5、6 章)

这是本材料最”研究向”的两节,也是第 4 题的正面回答。

路线一:TF(词频)+ 余弦相似度

给定句子 A=”我喜欢深度学习”、B=”我讨厌深度学习”,词表 ["我","喜欢","讨厌","深度","学习"]:

句子 我 喜欢 讨厌 深度 学习
A 1 1 0 1 1
B 1 0 1 1 1

\cos(A,B) = \frac{3}{\sqrt{4}\cdot\sqrt{4}} = 0.75

0.75 太高了,两个情感相反的句子被算成了高度相似,因为 unigram 根本分不清”喜欢”和”讨厌”。

加上 2-gram 后,特征表变成 10 维,两个句子的向量:

句子 我 喜欢 讨厌 深度 学习 我 喜欢 喜欢 深度 深度 学习 我 讨厌 讨厌 深度
A 1 1 0 1 1 1 1 1 0 0
B 1 0 1 1 1 0 0 1 1 1

\cos(A,B) = \frac{4}{\sqrt{7}\cdot\sqrt{7}} = \frac{4}{7} \approx 0.57

材料给的评价是:”添加 2-gram 后,相似度降低,更能反映差异。”

一个要提醒的细节:材料这两行公式里,unigram 那一步写的是 \frac{3}{\sqrt{4}\cdot\sqrt{4}}=0.75(正确:两个向量的点积就是 3,各含 4 个 1,模长都是 2);但 2-gram 那一步材料写的是 \frac{3}{\sqrt{7}\cdot\sqrt{7}}\approx 0.57,分子笔误了:两个 7 维向量(各含 7 个 1)的点积是 4(共有的是”我/深度/学习/深度 学习”四项),4/7\approx0.5714 才对得上 0.57;按材料写的 3/7 算出来是 0.4286,与它自己的结论矛盾。结论方向(加 2-gram 后相似度下降)没问题,抄公式时按 4/7 记。

代码用 sklearn 一遍跑完三种 n-gram 配置(材料代码,我复现过输出):

import jieba
from sklearn.feature_extraction.text import CountVectorizer, TfidfVectorizer
from sklearn.metrics.pairwise import cosine_similarity

texts = ["我喜欢深度学习", "我不喜欢深度学习"]

def chinese_tokenizer(text):
    return jieba.lcut(text)

for ngram in [(1,1), (1,2), (1,3)]:
    vectorizer = CountVectorizer(tokenizer=chinese_tokenizer, ngram_range=ngram)
    X = vectorizer.fit_transform(texts)
    print("特征:", vectorizer.get_feature_names_out())
    print("向量:\n", X.toarray())
    print("相似度:\n", cosine_similarity(X))

实测的相似度随 n-gram 单调下降(这是本节最值得背的一组数字):

向量化方式 ngram_range=(1,1) (1,2) (1,3)
CountVectorizer(TF) 0.894 0.756 0.674
TfidfVectorizer(TF-IDF) 0.818 0.615 0.515

越细的 n-gram,把两个句子的差异放得越大。但注意这不是免费的:n-gram 让特征维度爆炸(这句里 3-gram 的”学习”词表从 5 维涨到 14 维),而且数据稀疏、泛化差,所以它只适合小规模传统方法。

顺带一个工程细节:我复现时用了 TfidfVectorizer(tokenizer=jieba.lcut, token_pattern=None, ...)。sklearn 的默认 token_pattern 是 (?u)\b\w\w+\b,要求 token 至少两个字符,中文不分词时整句会被当成一个 token,即使已经 jieba 分好词,所有单字(我/不/的)也会被静默丢掉。中文 TF-IDF 必须改这个参数(或改成 r"(?u)\S+"),否则你会得到一个”莫名其妙少了一半特征”的词表。

路线二:SBERT 句向量 + 余弦

TF 路线是”词频统计”,不知道”喜欢”和”讨厌”是反义;SBERT 是”语义编码”,同义句(我喜欢深度学习 / 我热爱深度学习)应该相似度 0.8~0.95,无关句应该 < 0.2。

机制三步:

  1. BERT 编码上下文:每个 token 拿到一个融合了上下文的向量 h_1, h_2, \dots, h_n(”喜欢”的向量会因为它后面跟的是”深度”还是”讨厌”而不同);
  2. Mean Pooling 得到句向量:\mathbf{s} = \frac{1}{n}\sum_{i=1}^{n}\mathbf{h}_i;
  3. L2 归一化:\hat{\mathbf{s}} = \frac{\mathbf{s}}{\|\mathbf{s}\|},作用是”消除向量模长差异,只保留语义方向信息,便于向量检索与点积计算”。

归一化之后,余弦相似度退化成点积:

\cos(\theta) = \frac{\mathbf{s}_1 \cdot \mathbf{s}_2}{\|\mathbf{s}_1\|\|\mathbf{s}_2\|} = \hat{\mathbf{s}}_1 \cdot \hat{\mathbf{s}}_2

材料给的经验区间:同义句 ≈ 0.8~0.95;低相关 ≈ 0.3~0.5;无关句 < 0.2。

from sentence_transformers import SentenceTransformer
import numpy as np

model = SentenceTransformer("all-MiniLM-L6-v2")   # 384 维密集向量空间
sentences = ["我喜欢深度学习", "我喜欢玩游戏"]
embeddings = model.encode(sentences)

v1, v2 = embeddings
print(f"句向量维度:{v1.shape}")
print("句子相似度:", np.dot(v1, v2))
print("句子相似度:", model.similarity(embeddings, embeddings))

材料实测输出:

句向量维度:(384,)
句子相似度: 0.81849515
句子相似度: tensor([[1.0000, 0.8185],
                      [0.8185, 1.0000]])

“我喜欢深度学习”和”我喜欢玩游戏”得到 0.8185,语义确实相近(都喜欢),但不算同义句,落在材料的”同义句 0.8~0.95″区间下沿附近,符合”不应过高也不应接近 0″的预期。注意 encode() 里 normalize_embeddings=True 被注释掉了,此时直接用 np.dot(v1, v2) 得到 0.8185 是巧合般的接近(因为 MiniLM 输出的向量模长接近 1),严谨写法还是要开归一化,或者直接调 model.similarity()。

两条路线的对比(也是第 4 题的答案):

维度 TF + Cosine SBERT + Cosine
表示 词频/TF-IDF 稀疏向量,词表维度 384/768 维稠密句向量
词序与语义 丢词序(靠 n-gram 补救),无同义/反义概念 有上下文,能区分同义与反义
成本 极低,CPU 毫秒级,无需模型权重 需要预训练模型,GPU 明显更快
数据要求 无(纯统计) 无(用现成模型)或需要句对微调
典型用途 关键词匹配、小规模基线、可解释 语义检索、聚类、去重、洗数据
材料原话 “基于词频,轻量但语义能力弱” “基于上下文句向量,语义表达更强,工程中更常用”

五、易错点与常见误解

① 常见说法:”英文和中文用同一套切分逻辑就行”;实际上,英文按空格切、中文必须分词。

为什么:英文的词之间天然有空格,tokenizer 只要按空格/标点切;中文书写单位是字,词与词之间没有分隔符,字符序列 c_1c_2\cdots c_n 的切分方案数随长度指数增长,”南京市长江大桥”就有歧义。所以中文的”分词”是一个需要词典+统计模型的歧义消解任务,不是字符串操作。

② 常见说法:”全模式分词切得更全,训练用它没坏处”;实际上,建模只用精确模式。

原因在于,全模式会输出所有可能成词(实测 我/来到/北京/清华/清华大学/华大/大学),同一段文字被重复计数、产生大量无意义特征(”华大”),而且切分结果不唯一、不可复现。全模式是给检索召回用的,不是给建模用的。

③ 常见说法:”停用词表随便找一份通用的就行”;实际上,停用词表是任务相关的,必须人工过一遍。

背后的道理是,最常见的事故是通用表里含否定词「不/没/未/无」。情感分类里”不满意”去掉「不」就变成”满意”,标签直接反了。项目里 749 个停用词是给 TF-IDF 用的(材料明确写”用于后续 tf-idf 参数”),不是给所有模型通用的。

④ 常见说法:”max_length 就取句子里最长的那个长度,信息不丢”;实际上,取能覆盖绝大多数样本的分位数长度。

之所以如此,取最大值意味着绝大多数短样本被大量 PAD 填充,浪费算力,而且 PAD 会把 self-attention 的分布稀释、拉低有效信息占比。反过来取太短会切掉关键实体。正确做法是 4.1.2 的长度分布直方图上挑一个 95%~99% 分位的值。

⑤ 常见说法:”PAD 用 0 填充很自然”;实际上,0 是 <PAD> 的 id 才行,且 PAD 必须被 mask 掉。

为什么:如果词表里 0 已经被某个真实词占了(比如手工建表时没预留),补齐的 0 就会被模型当成真实词。而且即使 0 是 <PAD>,它仍然会进入 attention 计算,必须靠 padding_mask / attention_mask 屏蔽掉,否则短句的结果会被整片 PAD 影响。

⑥ 常见说法:”Keras pad_sequences 默认就是后面补/后面截”;实际上,默认是 padding='pre'、truncating='pre'。

原因在于,材料代码里显式传了 "post",容易让人误记成默认值。实际 API 默认是前面补、从头截(也就是保留句尾)。对新闻标题这类”关键词在头部”的文本,truncating='pre' 是有害的;而 RNN 场景官方建议用 post padding 才能走 cuDNN 加速实现。结论:这两个参数永远显式传。

⑦ 常见说法:”先在全量数据上建词表、算 IDF,再切训练/验证集,省事”;实际上,先切分,再只用训练集 fit。

背后的道理是,这是数据泄漏。TfidfVectorizer 的 fit 会同时建词表和算 idf_,如果在全量数据上 fit,验证集/测试集的词分布就通过 IDF 泄漏进了训练过程,报告的指标不可信。同一条纪律也适用于停用词表、词表大小、max_length 的统计。材料第 8 章第 6 题原话:”先划分数据集,再构建词表与统计特征;任何归一化/统计仅在训练集拟合,再应用到验证/测试集。”

⑧ 常见说法:”现代深度学习里也该加 n-gram 特征”;实际上,现代流程几乎不用。

之所以如此,材料自己写了”n-gram 特征主要在传统机器学习 NLP 流程中使用,现在深度学习 NLP 流程中几乎不用”。因为 BERT 这类模型的 self-attention 直接在 token 之间建模,n-gram 能提供的那点局部词序信息已经被完全覆盖,额外加只会带来维度爆炸和稀疏。n-gram 在今天的位置是:FastText 的 wordNgrams 参数、检索的倒排索引、以及传统基线。

⑨ 常见说法:”回译次数越多,生成的样本越多样”;实际上,最多 3 次。

为什么:每次翻译都是一次”同义改写”,误差会累积。材料的经验值是”最多只采用 3 次连续翻译,更多的翻译次数将产生语义失真”。另一头的风险是次数太少:短文本单次回译的重复率可能高到”并不能有效增大样本”,相当于白做。

⑩ 常见说法:”len(train_data['sentence']) 就是句子的词数”;实际上,它算的是字符数。

原因在于,len() 作用在字符串上返回字符数,中文里字符数≈词数的 1.5~2 倍。用字符数统计出来的长度分布去定按词索引的序列 max_length,会系统性偏大(长句被切得更狠或短句补得更长),必须和下游序列的单位对齐。

⑪ 常见说法:”材料里 n-gram 相似度的公式直接抄就行”;实际上,抄数值、核对公式。

背后的道理是,材料第 5.3 节写的是 \cos(A,B)=\frac{3}{\sqrt{7}\cdot\sqrt{7}}\approx0.57,两个 7 维向量(各含 7 个 1)的点积实际是 4,4/7\approx0.5714 才等于它给的 0.57,分子是笔误,按 3 算出来是 0.4286。被追问时如果被要求”现场推一下这个例子”,分子报错会很尴尬。这类”结论对、中间推导写错”的情况在课件里并不罕见,处理原则是数值和方向照记、公式自己验一遍。


六、横向对比与研究脉络

6.1 分词工具对比

工具 原理 优点 缺点 适用场景
空格切分(英文天然) 按空白符 split 零成本、可复现 中文不可用;标点粘连需额外清洗 英文语料
jieba 前缀词典 + DAG + 动态规划求最大概率路径;未登录词用 HMM+Viterbi 开箱即用、三种模式、支持自定义词典与词性标注、纯 Python 易部署 词典固定,领域新词需自己加;不感知上下文 中文预处理默认选择(课程全程使用)
HanLP 多模型可选(词典/CRF/深度学习) 功能全、词性体系规范 依赖重、配置复杂 需要高质量 POS/NER 的场景
LTP(哈工大讯飞)/ THULAC(清华)/ pkuseg(北大) 各自的分词+词性模型 学术与工业使用广泛 生态与中文文档不如 jieba 需要更细粒度标注时对比选型
字级切分 逐字成 token 零 OOV、词表小、鲁棒 序列变长、语义单元弱 BERT 中文常用;项目里 FastText 按字分词实测效果最好
BPE / WordPiece(子词) 从字符开始按频率合并(或按概率切分) 兼顾词表大小与 OOV 鲁棒性 需要预训练过程、切分结果不可读 BERT / GPT 等预训练模型的 tokenizer

6.2 三种”文本 → 向量”路线对比

维度 TF-IDF(+n-gram) FastText(词+子词) BERT / SBERT(上下文)
表示单元 词 词 + 字符 n-gram 子词 子词/字,带上下文
词序 无(n-gram 部分补偿) 弱(词向量平均 + wordNgrams) 强(self-attention)
OOV 直接丢弃(transform 时静默变全 0 向量) 可由子词组合出向量 子词切分,基本无 OOV
训练成本 秒级 CPU 分钟级 CPU GPU 小时级起
语义能力 弱,无语义相似概念 中,词向量有相似性 强,区分同义/反义
在本材料里的位置 第 5 章 TF+Cos 示例 第 7 章 Subword 第 6 章 SBERT 示例

6.3 研究脉络:谁解决了谁的什么问题

① 空格切分(英文)
   └─问题── 中文没有空格,词是什么都定义不了
      ↓
② 词典 + 统计分词(jieba:DAG + 动态规划 + HMM)
   └─问题── 词典固定 → 领域新词切错;词表 12k+ 且测试集必然有 OOV
      ↓
③ 子词建模(BPE / WordPiece / FastText 字符 n-gram)
   └─问题── 词向量是静态的,「苹果」的向量永远是一个值,分不清水果和公司
      ↓
④ 上下文向量(BERT / SBERT:token 向量 + Mean Pooling → 句向量)
   └─问题── 相似度计算需要两两拼接计算量过大 → 句向量 + 余弦检索

每一层都留着上一层的方法在合适的场景继续用:今天你做新闻分类,第一级基线仍然是 TF-IDF + 随机森林(新闻分类项目里就是 81.48%),因为它是分钟级的成本下限。

6.4 五种数据分析方法对比(材料第 1、2 章)

方法 目的 图表/API 产出的决策
标签数量分布 查类别均衡 sns.countplot 是否需要重采样/加权;二分类 ACC 基线 50%
句子长度分布 定 max_length sns.histplot(bins, kde) 截断补齐的长度(分位数)
正负样本长度散点 定位异常样本 sns.stripplot 人工审查清单
词频统计 看词表规模 jieba.lcut + chain + Counter 是否加停用词、是否限 max_features
高频形容词词云 查标签与语料是否矛盾 pseg 取 flag=="a" + WordCloud 语料质量整改

七、在新闻分类项目中的落地:18w 标题的预处理流水线

7.1 项目的数据规格(先把事实钉死)

项目 规格
任务 类今日头条新闻分类,10 个类别
数据来源 企业内部的成品数据
训练 / 验证 / 测试 18w / 1w / 1w
数据格式 \t 分隔了新闻标题和标签
停用词 749 个,用于后续 TF-IDF 参数
技术路线 TF-IDF+随机森林 → FastText → BERT 微调 → LLM(DeepSeek API)
部署 Flask(服务端)+ Streamlit(客户端)

材料只给了”749 个”这个数字,没有给它的来源。下面是实现上的常规做法(标出来避免被当成课件的原始说明):先用一份公开的中文停用词表(哈工大 / 百度 / 四川大学机器智能实验室等版本是业内常见的底表)作为起点,再用 18w 标题做词频统计,把”高频但对区分 10 个类别毫无贡献的词”补进去(新闻标题里大量出现的模板词、通用虚词都属于这一类),最后去重、排序、固定版本号,落盘成 stopwords.txt,项目里的 TF-IDF 脚本读的正是 ./data/data/stopwords.txt。关键纪律是:这份表一旦定下来就不能再改,训练/验证/测试/线上必须用同一份。而它只喂给 TF-IDF,FastText 和 BERT 都不套这个词表。

7.2 标题\t标签 到底怎么读

课件里酒店语料是带表头的 train.tsv(第一行是 sentence\tlabel),所以材料第 1 章的代码是直接:

train_data = pd.read_csv(r"./data/train.tsv", sep='\t')     # 默认 header=0,第一行当列名

而新闻分类项目的原始文件是一行一条、无表头的 标题\t标签,材料里的处理脚本是”逐行读取 → 去掉首尾空白 → 用 \t 切出文本与标签”。两种写法不能混:

import pandas as pd

# 写法 A:无表头原始文件(这种无表头原始文件)
df = pd.read_csv("./data/train.txt", sep="\t", header=None,
                 names=["text", "label"], encoding="utf-8")

# 写法 B:逐行处理时可读性最好,也更稳
rows = []
with open("./data/train.txt", encoding="utf-8") as f:
    for line in f:
        line = line.strip()                 # 去行尾 \n 和首尾空白
        if not line:
            continue                        # 跳过空行
        text, label = line.split("\t", 1)    # maxsplit=1:标题里万一还有 \t 也不会串列
        rows.append((text, label))
df = pd.DataFrame(rows, columns=["text", "label"])

\t 分隔的三个实际坑:

  1. 表头错位:无表头文件按写法 A 之外的方式读(比如直接用 pd.read_csv(path, sep='\t')),第一行标题会被当成列名,训练集凭空少一条,而且列名变成一个长句子。
  2. 串列:如果标题里本身含 \t(抓取残留),不写 maxsplit=1 就会 ValueError: too many values to unpack,或者 10 类标签错位。split("\t", 1) 是廉价的保险。
  3. 编码:中文文件不显式给 encoding="utf-8",在 Windows 上会按 GBK 解码,出现乱码时看起来像”分词坏了”,实际是读文件时就坏了。

7.3 完整流水线(八步)

┌ 1 读取 ──────── 逐行 split('\t', 1) → (标题, 标签);10 类映射为 0~9
├ 2 清洗 ──────── 去 URL / @提及 / 话题符号 / HTML 残留 / 多余空白;全角转半角
├ 3 分词 ──────── jieba.lcut(标题);项目专有词先 load_userdict 自定义词典
├ 4 停用词 ────── 过 749 词集合(只用在 TF-IDF 路线;否定词必须人工确认没被删)
├ 5 落盘 ──────── 用空格拼回字符串,写入 train_new.csv 的 words 列
│                 (材料脚本:只保留每行前 30 个元素做截断,验证集/测试集走完全相同的代码)
├ 6 TF-IDF 路线 ─ TfidfVectorizer(stop_words=停用词).fit_transform(训练集)
│                 → 随机森林 → 基线(项目里为 81.48%)
├ 7 FastText 路线 转成 __label__标签 文本 → train_supervised()(按字分词实测更好)
└ 8 BERT 路线 ─── 不套停用词,用 tokenizer 自带子词切分 + max_length 截断补齐

第 3 步的分词器与自定义词典,可以直接用我在 4.2 节实测的例子说明为什么需要:

import jieba
print(jieba.lcut("大模型项目用BERT做新闻标题分类"))
# ['大', '模型', '项目', '用', 'BERT', '做', '新闻标题', '分类']
jieba.add_word("大模型")     # 默认词典里没有这个词,会被切成 大/模型

新闻标题里满是新词、专有名词、缩写(人名、公司名、产品名),这些正好是分类信号最强的地方,而它们最容易在默认词典里被切碎。所以”分词 + 自定义词典 + 停用词”是项目里明确的一条链路(材料导图里写的就是这个顺序)。

第 5 步的”前 30 个词”就是这个材料 4.1.2 / 4.5 讲的长度规范在项目里的具体化:新闻标题短,30 个词足够覆盖绝大多数样本;三个模型复用同一份分词结果,避免重复分词,也保证”训练/验证/测试走完全相同的预处理代码”。

7.4 这份材料的知识在项目里各自钉在哪

材料章节 项目里的落点 关键参数 / 坑
1.2 标签数量分布 10 类的样本量必然不均,决定主指标不能只看 ACC 类别不均衡 → 看 precision/recall/F1 四件套
1.2 句子长度分布 定标题的截断长度(脚本用 30 个词) 经验值,要回头核对长度分布的均值/方差
1.2 词频统计 决定 TF-IDF 的 max_features / min_df 长尾词太多,不限制会让矩阵巨大且噪声高
2.1 n-gram 传统路线的可选增强(ngram_range=(1,2)) 只在 TF-IDF 路线上考虑;BERT 不需要
2.2 长度规范 三个模型都要:TF-IDF 侧截断、FastText 侧拼空格串、BERT 侧 max_length padding/truncating 显式传;PAD 要 mask
3.1 回译增强 样本量小或某类稀少时的补充手段 ≤3 次;生成样本必须先隔离,不能跨进验证集
4 词性对照 语料质检、关键词抽取、按词性过滤停用词 换工具要换标签体系
5 TF+Cos 语义相似度基线、类间相似度分析、错例分析 中文必须改 token_pattern
6 SBERT 语义检索 / 标签相似度分析 / 数据去重(清洗 18w 里的近似重复标题) 走 model.similarity() 或显式归一化
7 FastText Subword 项目第 2 级模型(按字分词实测最好) 子词缓解 OOV 的机制要能口述

四个模型共用一套预处理纪律(这是最容易被追问答不上来的地方):

  1. 分词口径:TF-IDF 和 FastText 都用 jieba 分好的 words 列(FastText 实测按字更好,所以它走的是”按字”那条线);BERT 用 tokenizer.batch_encode_plus() 自己的子词切分,不套 749 停用词。
  2. 切分纪律:18w/1w/1w 的划分在任何统计量(词表、IDF、max_length)之前完成。
  3. fit / transform 纪律:TfidfVectorizer 只在训练集上 fit_transform,验证/测试/线上只 transform。测试集上误用 fit_transform = 列对不上 + IDF 被污染。
  4. 一致性纪律:停用词表、自定义词典、jieba 版本、截断长度,任何一样变了都要重新跑全线;不一致不会报错,只会掉点。

八、重点问答

Q1:文本预处理都有哪些步骤?你在项目里是怎么做的?

A:我一般分三大块。第一块是先看数据,不急着清洗,用 countplot 看标签分布、用 histplot 看句子长度分布、用 stripplot 看正负样本长度散点、用 jieba 加 Counter 统计词频和词表大小、再用形容词词云抽检语料质量。这一块的产出很实在:长度分布决定我的 max_length,标签分布决定我的评估指标不能只看准确率。第二块是把文本变成模型能吃的特征,包括统一大小写和全半角、去 URL 和特殊符号、分词、过停用词、截断补齐到固定长度,传统路线上还会加 n-gram。第三块是数据增强,样本不够时用回译。我做的新闻标题分类里,18 万条数据先逐行按制表符切成标题和标签,再用 jieba 分词、过 749 个停用词、拼成空格串落盘,然后这份分词结果直接复用到 TF-IDF 和 FastText 两条线上。

Q2:中文分词为什么不能像英文那样按空格切?jieba 有哪几种模式,你用哪种?

A:英文单词之间天然有空格,按空格切就行;中文书写单位是字,词和词之间没有分隔符,一段 n 个字的文本可能有指数级的切分方案,”南京市长江大桥”就是经典歧义,所以中文分词本质是一个歧义消解任务。jieba 走的是前缀词典建 DAG、再用动态规划找最大概率路径,遇到词典里没有的连续汉字片段用 HMM 加 Viterbi 切。它有三种模式:精确模式是默认的、输出最可能的一条路径,实测”我来到北京清华大学”切成”我/来到/北京/清华大学”;全模式输出所有可能成词,会切出”清华/清华大学/华大/大学”这种重叠结果;搜索引擎模式是在精确模式基础上把长词再切出子词。建模我只用精确模式,全模式会重复计数、产生噪声,搜索引擎模式是给倒排索引和关键词匹配用的。另外我一定会挂自定义词典,因为新闻标题里的新词和专有名词在默认词典里容易被切碎。

Q3:停用词为什么要去?去掉有什么代价?

A:去的理由有两个:一是降维,中文里”的、了、是、在”这类词占 token 的比例很高,删掉词表能明显变小、矩阵更稀疏;二是降噪,这些词几乎每篇文档都有,区分度为 0。要强调一点:IDF 只能把它们的权重压低,删不掉矩阵里那一列,stop_words 参数是直接把列删掉,所以我们才额外准备了一份停用词表。代价是删错词会让语义翻车,最典型的是否定词”不、没、未、无”,情感分类里”不满意”去掉”不”就变成”满意”,标签直接反了。所以我有一条纪律:停用词表是任务相关的,不存在通用最优值,评论和情感类的任务必须人工过一遍词表,把否定词和程度副词留在里面。我们项目里那 749 个停用词只喂给 TF-IDF,FastText 和 BERT 都不套它。

Q4:max_length 怎么选?截断和补齐怎么实现?

A:max_length 不是拍脑袋来的,是先画句子长度分布直方图、取一个能覆盖绝大多数样本的分位数(经验上 95% 到 99%)。取最大值会让大量短句子被 PAD 填满,浪费算力,PAD 还会稀释 attention;取太小又会切掉关键信息。实现上补的元素是 <PAD>、索引 0,长句截断短句补齐。Keras 有现成的 pad_sequences,但要注意它默认是 padding='pre'、truncating='pre',也就是前面补、从头截、保留句尾,对新闻标题这种关键词在头部的文本就有害了,而且 TensorFlow 官方建议用 RNN 时用 post padding 才能走 cuDNN 加速。所以我这两个参数永远显式传。还有两个必须配套的动作:0 必须是 <PAD> 的 id,而且 PAD 位要用 attention mask 屏蔽掉,否则短句结果会被整片 PAD 影响。我们项目里新闻标题短,材料里这套长度统计落到工程上就是”每条标题只保留前 30 个词”。

Q5:n-gram 是什么?什么时候用、什么时候不用?

A:n-gram 是连续 n 个 token 组成的特征,用来捕捉局部词序。1-gram 是单词,2-gram 是相邻两词组合,3-gram 是三词组合,常用的就是 2 和 3。它的价值我一般用一个例子讲:句子”我喜欢深度学习”和”我讨厌深度学习”,用 unigram 的词频算余弦相似度是 0.75,明显偏高,因为词袋根本分不清喜欢和讨厌;加上 2-gram 之后相似度降到 0.57,更能反映差异。实测在 sklearn 里相似度会随 n-gram 变细单调下降,TF-IDF 从 ngram_range=(1,1) 的 0.818 降到 (1,3) 的 0.515。但边界也很清楚:n-gram 是传统机器学习路线的特征工程手段,现代深度学习流程几乎不用,因为 BERT 这类模型的 self-attention 本来就在 token 之间建模,n-gram 提供的那点局部信息已经被覆盖,加了只会带来维度爆炸和稀疏。今天它主要出现在三个地方:FastText 的 wordNgrams 参数、检索的倒排索引、以及传统基线的特征。

Q6:TF+Cosine 和 SBERT 算句子相似度有什么区别?

A:一句话概括:前者是词频统计,后者是语义编码。TF-IDF 加余弦是把句子变成词表维度上的稀疏向量,算出来的相似度只反映”用词重叠多少”,没有同义和反义的概念,所以”我喜欢深度学习”和”我讨厌深度学习”会得到 0.75 这种偏高的分数;它轻量、CPU 毫秒级、可解释,适合小规模基线和关键词匹配。SBERT 是先让 BERT 编码出带上下文的 token 向量,做 Mean Pooling 得到句向量,再做 L2 归一化,最后算余弦;归一化之后余弦就退化成点积,方便做向量检索。它的语义能力强,同义句大概 0.8 到 0.95、低相关 0.3 到 0.5、无关句小于 0.2。我实测过”我喜欢深度学习”和”我喜欢玩游戏”用 all-MiniLM-L6-v2 得到 0.8185,模型输出是 384 维,语义相近但不完全同义,数值落在这个区间的下沿,符合直觉。工程上清洗数据、做语义去重和检索我会选 SBERT,做快速基线对比或者资源受限的场景就用 TF-IDF。

Q7:OOV 是什么?有哪些手段处理?

A:OOV 是未登录词,就是推理时出现、但训练词表里没有的 token。它的危害在传统路线上特别隐蔽:TfidfVectorizer.transform() 遇到词表外的词是静默丢弃的,一条全是生词的文本会得到一个全 0 向量,不报错,线上就是一个相当于瞎猜的结果。处理手段分三层:第一层是词表设计,一定要预留 <UNK> 和 <PAD>,OOV 统一映射到 <UNK>,别让它把索引打乱;第二层是子词建模,FastText 会把词拆成字符 n-gram(常用 3 到 6),词向量由子词向量聚合,等于未见过的新词也能用它的子词拼出向量,这就是它对 OOV 更鲁棒的原因,BERT 的 WordPiece 也是同一思路;第三层是分词粒度,中文按字切分基本没有 OOV,我们项目里 FastText 四组实验的结果就是”按字分词 + 手动调参”最好,原因之一就是按词分词会让测试集遇到更多生词。

Q8:怎么避免训练和验证之间的数据泄漏?

A:三条纪律。第一,先划分数据集,再做任何统计,词表、IDF、停用词表、max_length 这些统计量都只能在训练集上算,否则验证集的信息会通过它们泄漏进训练过程。第二,fit 和 transform 严格分开:TfidfVectorizer.fit_transform() 只能在训练集上用一次,验证集、测试集、线上推理一律用 transform(),复用训练集的词表和 idf_;如果在测试集上误用 fit_transform,既会让特征列对不上,也会让 idf_ 被测试分布污染,报出来的指标不可信。第三,预处理代码要完全一致,训练、验证、测试走同一套分词、停用词和截断逻辑,任何一样变了都不报错、只是指标悄悄掉。另外做数据增强的时候,生成的样本必须和验证集隔离,不能把回译出来的句子混进验证集。


九、延伸阅读

数据集 / 语料
– 中文酒店评论语料(本材料第 1 章使用的二分类情感分析语料,train.tsv 2960 条 / dev.tsv 1000 条)

工具与官方文档
– jieba 分词:https://github.com/fxsjy/jieba (三种模式、自定义词典、词性标注、pseg)
– OpenCC 繁简转换:https://github.com/BYVoid/OpenCC
– scikit-learn 文本特征提取 CountVectorizer / TfidfVectorizer(注意 token_pattern 默认 (?u)\b\w\w+\b 对中文的影响):https://scikit-learn.org/stable/modules/feature_extraction.html
– TensorFlow / Keras pad_sequences API(padding / truncating 默认值均为 pre):https://www.tensorflow.org/api_docs/python/tf/keras/utils/pad_sequences
– TensorFlow 官方 masking & padding 指南(RNN 场景建议 post padding):https://www.tensorflow.org/guide/keras/understanding_masking_and_padding
– Sentence-Transformers(SBERT)与 all-MiniLM-L6-v2:https://www.sbert.net/
– FastText(subword 建模与预训练词向量):https://fasttext.cc/
– deep_translator / translate(回译增强用的免费翻译接口)
– WordCloud(词云):https://github.com/amueller/word_cloud
– 中文停用词表常用来源(多份可对照,用于自建项目词表的底表):https://github.com/goto456/stopwords

论文 / 综述方向(按主题检索,不锁定具体年份与作者)
– Chinese word segmentation 的词典 + 统计混合方法(jieba 所属的技术路线)
– Subword / BPE / WordPiece 与 OOV 鲁棒性
– Sentence-BERT:句向量与语义相似度
– 数据增强综述:回译(back-translation)与同义改写

暂无评论

发送评论 编辑评论


				
|´・ω・)ノ
ヾ(≧∇≦*)ゝ
(☆ω☆)
(╯‵□′)╯︵┴─┴
 ̄﹃ ̄
(/ω\)
∠( ᐛ 」∠)_
(๑•̀ㅁ•́ฅ)
→_→
୧(๑•̀⌄•́๑)૭
٩(ˊᗜˋ*)و
(ノ°ο°)ノ
(´இ皿இ`)
⌇●﹏●⌇
(ฅ´ω`ฅ)
(╯°A°)╯︵○○○
φ( ̄∇ ̄o)
ヾ(´・ ・`。)ノ"
( ง ᵒ̌皿ᵒ̌)ง⁼³₌₃
(ó﹏ò。)
Σ(っ °Д °;)っ
( ,,´・ω・)ノ"(´っω・`。)
╮(╯▽╰)╭
o(*////▽////*)q
>﹏<
( ๑´•ω•) "(ㆆᴗㆆ)
😂
😀
😅
😊
🙂
🙃
😌
😍
😘
😜
😝
😏
😒
🙄
😳
😡
😔
😫
😱
😭
💩
👻
🙌
🖕
👍
👫
👬
👭
🌚
🌝
🙈
💊
😶
🙏
🍦
🍉
😣
Source: github.com/k4yt3x/flowerhd
颜文字
Emoji
小恐龙
花!
上一篇
下一篇