17  非结构化文本处理

17.1 问题、先修与学习目标

年报、临时公告、问询函和系统日志都是文本,但它们不是同一种数据。年报是长文档,存在页眉、页码、目录、表格与固定法律表述;日志则通常有时间戳、级别、模块和消息的半结构化边界。文本特征工程的核心不是删掉越多符号越好,而是在保留业务语义的前提下,建立从原始字节到稀疏数值矩阵的可审计映射。

本章先修是 Python 字符串、集合和函数,正则表达式的字符类与捕获组,以及线性代数中向量范数和内积。学完后,读者应能够:

  • 读取本地年报 Markdown,从文件名提取证券代码、年份和公司名;

  • 区分年报清洗与日志解析,为数值、页码、URL 和字段边界设计可验证的正则模式;

  • 使用 jieba 精确模式、金融自定义词和停用词完成中文分词;

  • 从文档—词项矩阵定义 BoW,并严格按 sklearn 默认 smooth_idf=True 推导 TF–IDF;

  • 在不将全特征矩阵稠密化的条件下检查非零元素、高权重词和内存占用;

  • 解释 BoW/TF–IDF 的词序损失、词汇漂移、长文档偏差和拟合泄漏边界。

本章按下表把每个正式目标落到可观察的核心答案。

表 17.1: 第十七章目标—活动—核心题映射
正式目标 正文活动 核心题与答案证据
年报文件与元数据血缘 小节 17.2 练习 17.1 的完整文件名匹配断言
年报清洗与日志解析边界 小节 17.3 练习 17.5 的双对象规则审计
jieba 自定义词与停用词 小节 17.4 练习 17.5 的领域词和否定词断言
BoW 与默认 TF–IDF 公式 小节 17.5小节 17.6 练习 17.2 的手工 IDF 核验
稀疏矩阵检查 小节 17.6.2 练习 17.3 与 17.4 的 CSR 和密度审计
方法与泄漏边界 小节 17.7小节 17.7.1小节 17.7.2 练习 17.4 与 17.5 的局限解释

表 17.1 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

17.2 本地年报语料与数据血缘

本章直接使用本地 annual_report/ 目录中的 2024 年年报 Markdown,样本公司分别来自上海、江苏、浙江和安徽。列表 17.1 固定并验证语料文件。选定的六家公司横跨汽车、智能物联、医药、软件、银行与港口,便于观察行业词汇的区分性。

表 17.2 把每个本地文件绑定到巨潮资讯的法定披露全文、披露日与本地字节哈希。六份 Markdown 的历史 PDF→Markdown 转换日志均没有随语料保存,因此转换工具和版本只能标为『未记录(不可证实)』;文件时间也只能证明本地副本在该时刻存在,不能倒推出下载或转换流程。这里明确暴露缺口,而不根据排版特征猜测工具。公开公告不等于开放许可:教材可链接官方 PDF,并在授权教学环境内分析本地副本,但不把 PDF 或 Markdown 全文打包进仓库或再分发。

表 17.2: 六份年报的逐文件官方出处、披露日、转换限制与本地快照
本地文件 官方公告全文;披露日 本地文件时间;历史转换工具/版本 SHA-256
600104_2024_上汽集团_年报.md 巨潮资讯全文;2025-04-30 2026-04-25T03:18:59+08:00;未记录(不可证实) 3c1d47bb9b917f530cc503a7b787f8d1e172cfce065657d028238b918d5ea217
002415_2024_海康威视_年报.md 巨潮资讯全文;2025-04-19 2026-04-25T00:02:11+08:00;未记录(不可证实) 013e32cc666a09717b8fde8bc63ac280c3c501ba3365379d30f948c7e799b4d6
600276_2024_恒瑞医药_年报.md 巨潮资讯全文;2025-03-31 2026-04-25T03:35:24+08:00;未记录(不可证实) f2694a5ba99d8bb81cb6549bfe1b8a15a3f201062efb82489eb37512923dd2c0
002230_2024_科大讯飞_年报.md 巨潮资讯全文;2025-04-22 2026-04-24T23:44:02+08:00;未记录(不可证实) cd564288d9c4338ddce981c46e43f90b0debfd7c441a172308977f65a09107de
002142_2024_宁波银行_年报.md 巨潮资讯全文;2025-04-10 2026-04-24T23:35:55+08:00;未记录(不可证实) e8fff58bf3d773d8a1fb1421063dba9961d7536a340cbd168453e6ffb7b6680a
601018_2024_宁波港_年报.md 巨潮资讯全文;2025-03-28 2026-04-25T04:37:12+08:00;未记录(不可证实) 1846363c104d0827ba823737b86f140af7f8f7767f6c7003d0274ea6e3046711

六份文本合计不足 5 MB,因此普通渲染可直接验证其 SHA-256;数百 MB 至 GB 的行情文件则只在发布审计时做全量哈希,普通渲染使用 schema、行数与固定日期样本检查。两级策略既阻止语料静默漂移,也避免每次渲染顺序读取整套巨型行情。

列表 17.1: 读取六家长三角上市公司的本地年报 Markdown
import platform  # 按操作系统选择项目数据根目录
from pathlib import Path  # 以跨平台对象管理年报文件路径
import hashlib  # 对体量适中的六份年报执行逐文件字节哈希核验
import re  # 为文件名、年报噪声和日志字段建立正则规则
import numpy as np  # 处理稀疏聚合结果和权重排序
import pandas as pd  # 保存文档元数据与检查表
DATA_ROOT = 'C:/qiufei/data' if platform.system() == 'Windows' else '/home/ubuntu/r2_data_mount/data'  # 遵循教材统一路径规范
ANNUAL_REPORT_ROOT = Path(DATA_ROOT) / 'annual_report'  # 将语料范围限定在本地年报目录
report_filenames = ['600104_2024_上汽集团_年报.md', '002415_2024_海康威视_年报.md', '600276_2024_恒瑞医药_年报.md', '002230_2024_科大讯飞_年报.md', '002142_2024_宁波银行_年报.md', '601018_2024_宁波港_年报.md']  # 固定可复现的长三角语料清单
report_paths = [ANNUAL_REPORT_ROOT / filename for filename in report_filenames]  # 将相对文件名转为完整本地路径
assert all(report_path.is_file() for report_path in report_paths)  # 在分析前显式验证每份真实年报存在
expected_report_sha256 = {'600104_2024_上汽集团_年报.md': '3c1d47bb9b917f530cc503a7b787f8d1e172cfce065657d028238b918d5ea217', '002415_2024_海康威视_年报.md': '013e32cc666a09717b8fde8bc63ac280c3c501ba3365379d30f948c7e799b4d6', '600276_2024_恒瑞医药_年报.md': 'f2694a5ba99d8bb81cb6549bfe1b8a15a3f201062efb82489eb37512923dd2c0', '002230_2024_科大讯飞_年报.md': 'cd564288d9c4338ddce981c46e43f90b0debfd7c441a172308977f65a09107de', '002142_2024_宁波银行_年报.md': 'e8fff58bf3d773d8a1fb1421063dba9961d7536a340cbd168453e6ffb7b6680a', '601018_2024_宁波港_年报.md': '1846363c104d0827ba823737b86f140af7f8f7767f6c7003d0274ea6e3046711'}  # 固定本轮实际分析的六个 UTF-8 文件字节快照
raw_report_bytes = [report_path.read_bytes() for report_path in report_paths]  # 一次读取同时服务哈希与文本解码,避免重复磁盘访问
observed_report_sha256 = {report_path.name: hashlib.sha256(report_bytes).hexdigest() for report_path, report_bytes in zip(report_paths, raw_report_bytes)}  # 计算六个小文件的实际 SHA-256
assert observed_report_sha256 == expected_report_sha256  # 在固定词频或 TF-IDF 结果计算前阻断语料漂移
raw_report_texts = [report_bytes.decode('utf-8') for report_bytes in raw_report_bytes]  # 通过哈希后再按统一 UTF-8 解码全文

17.2.1 用正则表达式解析元数据

文件名是数据血缘的一部分。模式 ^(?P<code>\d{6})_(?P<year>\d{4})_(?P<company>.+?)_年报\.md$ 使用命名捕获组提取代码、年份和公司。^$ 要求整个文件名完整匹配,避免『看起来像』却夹带其他后缀的文件静默进入语料库。解析后的语料清单见 表 17.3

filename_pattern = re.compile(r'^(?P<code>\d{6})_(?P<year>\d{4})_(?P<company>.+?)_年报\.md$')  # 用命名组规定年报文件名语法
metadata_records = []  # 收集每份文档的可追溯元数据
for report_path, raw_text in zip(report_paths, raw_report_texts):  # 对齐文件路径和已读取的原文
    filename_match = filename_pattern.fullmatch(report_path.name)  # 要求文件名完整符合命名规范
    assert filename_match is not None  # 禁止元数据无法解析的文档静默进入语料
    parsed_fields = filename_match.groupdict()  # 取得代码、年份和公司名命名组
    metadata_records.append({'stock_code': parsed_fields['code'], 'report_year': int(parsed_fields['year']), 'company': parsed_fields['company'], 'filename': report_path.name, 'raw_characters': len(raw_text)})  # 记录文档身份与原始规模
report_metadata = pd.DataFrame(metadata_records)  # 建立一行一文档的元数据表
report_metadata  # 展示本章实际使用的真实语料血缘
表 17.3: 由真实年报文件名提取的语料元数据
stock_code report_year company filename raw_characters
0 600104 2024 上汽集团 600104_2024_上汽集团_年报.md 453753
1 002415 2024 海康威视 002415_2024_海康威视_年报.md 314239
2 600276 2024 恒瑞医药 600276_2024_恒瑞医药_年报.md 249349
3 002230 2024 科大讯飞 002230_2024_科大讯飞_年报.md 869875
4 002142 2024 宁波银行 002142_2024_宁波银行_年报.md 527959
5 601018 2024 宁波港 601018_2024_宁波港_年报.md 621169

17.3 公告与日志的正则边界

正则表达式应用于结构稳定、可明确验证的模式,不应试图一次性理解整份年报。两种文本需要不同思路:

  • 年报中的 12/219 是页码,URL 是排版残留,百分比和金额则可能是金融信息。基线模型将数字统一映射为『数值』,而不是简单删除;

  • 日志常见语法是『ISO 时间戳—级别—模块—消息』。解析模式需要锚定行首,并将消息保留为最后一个宽字段,否则消息内部的空格会被误拆;

  • 多行堆栈跟踪不能逐行独立解析。它们必须先根据新时间戳的行首识别记录边界,再把续行归并到上一条记录。

两类文本的可复用模式统一列于 列表 17.2

列表 17.2: 年报清洗与系统日志解析的正则模式
page_number_pattern = re.compile(r'(?m)^\s*\d+\s*/\s*\d+\s*$')  # 只在独立行中识别年报页码以避免删除比率
url_pattern = re.compile(r'https?://\S+|www\.\S+', flags=re.IGNORECASE)  # 识别排版或引用遗留的网络地址
numeric_pattern = re.compile(r'(?<![A-Za-z])[-+]?\d[\d,]*(?:\.\d+)?%?')  # 将常见金额与百分比统一为数值占位词
non_lexical_pattern = re.compile(r'[^\u4e00-\u9fffA-Za-z\s]')  # 在数值映射后删除 Markdown 边界符号与标点
whitespace_pattern = re.compile(r'\s+')  # 将分页和表格产生的连续空白归一化
log_record_pattern = re.compile(r'^(?P<timestamp>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2}))\s+(?P<level>DEBUG|INFO|WARNING|ERROR|CRITICAL)\s+(?P<module>\S+)\s+(?P<message>.*)$')  # 为带时区 ISO 日志定义四个命名字段

日志模式没有被强行用到年报上,因为那会将两种不同数据语法混为一谈。生产日志还应对时间戳使用 Asia/Shanghai 或明确时区偏移做语义验证,不能只因字符串匹配就认为时间正确。年报专用清洗器见 列表 17.3

列表 17.3: 保留金融数值语义的年报清洗函数
def clean_annual_report(raw_text):  # 将年报排版噪声转换为可分词文本
    '''删除页码与链接,将数字归一为业务占位词。'''
    cleaned_text = page_number_pattern.sub(' ', raw_text)  # 移除独立页码行而不伤及正文比率
    cleaned_text = url_pattern.sub(' ', cleaned_text)  # 移除不参与词汇语义的 URL
    cleaned_text = numeric_pattern.sub(' 数值 ', cleaned_text)  # 保留『此处出现数量』而消除具体数值稀疏性
    cleaned_text = non_lexical_pattern.sub(' ', cleaned_text)  # 删除表格线、项目符号和普通标点
    return whitespace_pattern.sub(' ', cleaned_text).strip()  # 归一化空白并去除文档边界空格
cleaned_report_texts = [clean_annual_report(raw_text) for raw_text in raw_report_texts]  # 对六份真实年报使用同一确定性规则

17.4 中文分词、词典与停用词

中文没有天然词空格。jieba 基于词典构建有向无环图,再寻找总词频概率较高的分词路径;未登录词还会借助统计模型识别。自定义词典的作用不是『提高所有分词准确率』,而是显式保护『海康威视』、『不良贷款率』这类业务完整词;领域词典设置见 列表 17.4

停用词过滤是任务依赖的。『不』在一般词表中可能是停用词,但在『不存在重大遗漏』中改变了句子极性,因此本章不删除否定词。只保留长度大于 1 的词是教学基线,它会丢失『碳』、『铝』等单字行业词,正式项目应依词表保留而不应一刀切。六份年报的处理规模见 表 17.4

列表 17.4: 配置长三角公司与金融语汇的 jieba 词典
import jieba  # 使用中文词图与动态规划分割连续文本
jieba.setLogLevel(30)  # 抑制加载词典日志以保持教材输出简洁
custom_financial_terms = ['上汽集团', '海康威视', '恒瑞医药', '科大讯飞', '宁波银行', '宁波港', '管理层讨论与分析', '不良贷款率', '研发投入', '现金流量']  # 定义应保持完整的公司与业务词
for financial_term in custom_financial_terms:  # 将领域知识逐词加入分词器
    jieba.add_word(financial_term, freq=100000)  # 以高词频减少业务专名被误拆的概率
chinese_stopwords = {'的', '了', '和', '与', '及', '于', '在', '为', '是', '对', '将', '等', '以', '由', '中', '本', '公司', '股份', '有限公司', '报告期', '年度报告'}  # 移除高频虚词与年报固定模板词
protected_single_character_terms = {'不', '无', '未', '碳', '铝'}  # 保护会改变极性或具有行业含义的单字词
def tokenize_chinese_text(cleaned_text):  # 将已清洗中文转为空格分隔词序列
    '''使用精确模式分词,并过滤停用词与非业务单字。'''
    candidate_tokens = jieba.lcut(cleaned_text, cut_all=False)  # 使用适合文本分类的非重叠精确模式
    retained_tokens = [token for token in candidate_tokens if (len(token) > 1 or token in protected_single_character_terms) and token not in chinese_stopwords and not token.isspace()]  # 去除模板词并保留关键否定或行业单字
    return ' '.join(retained_tokens)  # 用空格明确标记分词边界供向量化器复用
tokenized_report_texts = [tokenize_chinese_text(cleaned_text) for cleaned_text in cleaned_report_texts]  # 对全部真实年报使用同一分词规则
tokenization_audit = report_metadata.loc[:, ['stock_code', 'company', 'raw_characters']].copy()  # 以文档身份为基础建立质量检查表
tokenization_audit['cleaned_characters'] = [len(cleaned_text) for cleaned_text in cleaned_report_texts]  # 计算清洗后字符规模
tokenization_audit['retained_tokens'] = [len(tokenized_text.split()) for tokenized_text in tokenized_report_texts]  # 计算过滤后词元数
tokenization_audit  # 展示清洗和分词是否导致异常信息损失
表 17.4: 六份真实年报的清洗与分词规模
stock_code company raw_characters cleaned_characters retained_tokens
0 600104 上汽集团 453753 177790 57956
1 002415 海康威视 314239 216253 70492
2 600276 恒瑞医药 249349 189705 60585
3 002230 科大讯飞 869875 269381 84228
4 002142 宁波银行 527959 170974 54919
5 601018 宁波港 621169 230200 74332

17.5 词袋模型与稀疏矩阵

设语料库有 \(N\) 篇文档,词表 \(V={t_1,…,t_p}\)。词袋矩阵 \(C∈ℕ_0^{N×p}\) 的元素由 式 17.1 定义:

\[ C_{d,j}=\sum_{r=1}^{L_d}\mathbf 1\{w_{d,r}=t_j\}, \tag{17.1}\]

其中 \(w_{d,r}\) 是文档 \(d\) 的第 \(r\) 个词。BoW 保留词频,但丢失词序;『不存在重大风险』和『存在重大风险』可能有非常相似的单词计数。可以用 n-gram 部分恢复局部词序,代价是更大词表和更稀疏的矩阵。

from sklearn.feature_extraction.text import CountVectorizer  # 将空格分词序列映射为稀疏计数矩阵
bow_vectorizer = CountVectorizer(tokenizer=str.split, token_pattern=None, lowercase=False, min_df=2, max_df=0.95, max_features=2500)  # 限制词表规模并去除只出现一篇或全部文档的模板词
bow_feature_matrix = bow_vectorizer.fit_transform(tokenized_report_texts)  # 直接生成 CSR 稀疏文档—词项矩阵
bow_terms = bow_vectorizer.get_feature_names_out()  # 保存列索引到中文词的可解释映射
corpus_term_counts = np.asarray(bow_feature_matrix.sum(axis=0)).ravel()  # 仅将 1×p 的词频聚合向量转为稠密数组
top_count_positions = np.argsort(corpus_term_counts)[-12:][::-1]  # 定位语料库词频最高的十二个词
bow_summary = pd.DataFrame({'项目': ['文档数', '词汇数', '非零元素', '矩阵密度'], '数值': [bow_feature_matrix.shape[0], bow_feature_matrix.shape[1], bow_feature_matrix.nnz, bow_feature_matrix.nnz / np.prod(bow_feature_matrix.shape)]})  # 审计稀疏特征矩阵的规模
top_count_table = pd.DataFrame({'词项': bow_terms[top_count_positions], '全语料词频': corpus_term_counts[top_count_positions]})  # 只提取有解释价值的小型排名表
bow_summary.round(4)  # 先展示稀疏性与维度概况
表 17.5: 本地年报的 BoW 稀疏矩阵与高频词
项目 数值
0 文档数 6.0000
1 词汇数 2500.0000
2 非零元素 8693.0000
3 矩阵密度 0.5795
top_count_table  # 展示从稀疏矩阵的列聚合中得到的高频词
表 17.6: 本地年报语料库的高频 BoW 词项
词项 全语料词频
0 宁波 1005
1 汽车 821
2 舟山 738
3 千元 519
4 杭州 436
5 排放 388
6 码头 348
7 限制性 329
8 宁波银行 319
9 全文 310
10 AI 309
11 百万元 302

表 17.5 报告矩阵维度与稀疏性,表 17.6 展示列聚合后的高频词。min_df=2 排除只在一份年报出现的词,可以抑制 OCR 残留,但也可能删除真正独特的业务风险词。max_df=0.95 在六篇文档中意味着出现于全部六篇的词会被排除。这些阈值必须根据语料规模披露,不是通用最优值。

17.6 TF–IDF:与 sklearn 默认实现完全一致

17.6.1 smooth_idf 和 L2 归一化

\(df(t)\) 是包含词 \(t\) 的文档数,\(N\) 是语料库文档数。TfidfVectorizer 默认使用 smooth_idf=True,其 IDF 严格为

\[ \operatorname{idf}(t)=\log\left(\frac{1+N}{1+df(t)}\right)+1. \tag{17.2}\]

分子和分母都加 1,对数之外再加 1。这与 \(\log[N/(1+df)]\) 不同,也与仅在分母加 1 不同。默认 sublinear_tf=False,因此 TF 是原始词频 \(C_{d,t}\),未归一权重由 式 17.3 给出:

\[ v_{d,t}=C_{d,t}\operatorname{idf}(t). \tag{17.3}\]

默认 norm='l2',最终输出为

\[ x_{d,t}=\frac{v_{d,t}}{\sqrt{\sum_{s\in V}v_{d,s}^2}}. \tag{17.4}\]

因此每个非空文档向量的 L2 范数为 1。这一归一化减轻了年报长度差异,但长文档仍可能因覆盖更多主题而具有不同词汇分布。手工与库实现的逐词核对见 表 17.7

from sklearn.feature_extraction.text import TfidfVectorizer  # 使用 sklearn 默认平滑 IDF 和 L2 归一化
tfidf_vectorizer = TfidfVectorizer(tokenizer=str.split, token_pattern=None, lowercase=False, min_df=2, max_df=0.95, max_features=2500, smooth_idf=True, sublinear_tf=False, norm='l2')  # 显式列出与正文公式对应的参数
tfidf_feature_matrix = tfidf_vectorizer.fit_transform(tokenized_report_texts)  # 保持 CSR 稀疏布局生成 TF–IDF 矩阵
tfidf_terms = tfidf_vectorizer.get_feature_names_out()  # 取得稀疏矩阵列到中文词的映射
document_frequency = np.asarray((tfidf_feature_matrix > 0).sum(axis=0)).ravel()  # 仅稠密化 1×p 的文档频率聚合向量
manual_idf = np.log((1 + tfidf_feature_matrix.shape[0]) / (1 + document_frequency)) + 1  # 严格实现 @eq-ch17-sklearn-smooth-idf
assert np.allclose(manual_idf, tfidf_vectorizer.idf_)  # 验证正文公式与当前 sklearn 实现一致
idf_check_positions = np.argsort(tfidf_vectorizer.idf_)[-10:][::-1]  # 选取 IDF 最高的十个可区分词
idf_verification = pd.DataFrame({'词项': tfidf_terms[idf_check_positions], '文档频率': document_frequency[idf_check_positions], '手工 IDF': manual_idf[idf_check_positions], 'sklearn IDF': tfidf_vectorizer.idf_[idf_check_positions]})  # 展示公式两侧的具体核对
idf_verification.round(4)  # 以四位小数报告 IDF 一致性
表 17.7: sklearn smooth_idf 与手工公式的数值核对
词项 文档频率 手工 IDF sklearn IDF
0 高速公路 2 1.8473 1.8473
1 API 2 1.8473 1.8473
2 鼓励类 2 1.8473 1.8473
3 高频 2 1.8473 1.8473
4 高压 2 1.8473 1.8473
5 VOCs 2 1.8473 1.8473
6 Technology 2 1.8473 1.8473
7 SaaS 2 1.8473 1.8473
8 SO 2 1.8473 1.8473
9 SG 2 1.8473 1.8473

17.6.2 不稠密化的高权重词检查

整个 \(N×p\) 特征矩阵只保留非零元素。为查看某份年报,只需取得一个稀疏行的 indicesdata,再对该行的非零权重排序。列表 17.5 封装了这一 top-k 操作,避免对全矩阵调用 .toarray()

列表 17.5: 从单个稀疏文档行提取高权重词
def extract_sparse_top_terms(sparse_matrix, feature_names, document_position, top_count=10):  # 仅访问目标文档的非零元素
    '''返回指定稀疏行中权重最高的词项,不稠密化特征矩阵。'''
    sparse_row = sparse_matrix.getrow(document_position)  # 以 CSR 形式取得单个文档行
    ranked_local_positions = np.argsort(sparse_row.data)[-top_count:][::-1]  # 只对该行已存储的非零权重排序
    selected_columns = sparse_row.indices[ranked_local_positions]  # 将行内位置映射回全局词表列
    return pd.DataFrame({'词项': feature_names[selected_columns], '权重': sparse_row.data[ranked_local_positions]})  # 仅构造 top-k 小型可读表
saic_document_position = int(report_metadata.index[report_metadata['company'].eq('上汽集团')][0])  # 依元数据定位上汽年报而不假定文件顺序
saic_top_terms = extract_sparse_top_terms(tfidf_feature_matrix, tfidf_terms, saic_document_position, top_count=12)  # 从上汽稀疏行提取高权重词
saic_top_terms.round(4)  # 显示词项及其 L2 归一化 TF–IDF 权重
表 17.8: 上汽集团年报的高权重 TF–IDF 词项
词项 权重
0 汽车 0.8576
1 集团股份 0.3256
2 整车 0.1190
3 环球 0.0941
4 零部件 0.0877
5 流动资产 0.0767
6 出资 0.0738
7 mg 0.0736
8 换届 0.0661
9 汽车集团 0.0661
10 乘用车 0.0642
11 上期 0.0602

表 17.8 中的词是『在这六份文档的当前预处理和词表下,对该公司相对突出』,不是管理层认定的重要性,也不是投资建议。词项排名会随语料库、停用词、min_df 和分词词典改变。

17.7 BoW 与 TF–IDF 的比较和适用边界

维度 BoW TF–IDF
基本数值 原始非负词频 词频×平滑 IDF,默认再做 L2 归一化
高频共性词 可能支配文档长度 IDF 较低,权重被抑制
文档长度 计数随长度增大 L2 归一化降低尺度差异
可解释性 直接解释为出现次数 解释为语料条件下的相对权重
典型任务 词频统计、朴素贝叶基线 信息检索、线性分类、相似度基线

17.7.1 样本切分与词汇泄漏

fit 不只计算 IDF,还决定词表、文档频率阈值和高频截断。在文本分类中,向量化器必须位于 Pipeline 内部并在每个训练折重新拟合。年报预测还应按披露时间切分,不能让未来年度新出现的业务词提前进入词表。同一公司跨年文档高度相似,若要评估跨公司泛化,还需按公司分组。

17.7.2 方法失效的场景

  • BoW 与 TF–IDF 忽略长距离语序和篇章结构,对否定、转折、指代和跨段落风险描述的理解有限。

  • 年报模板大量重复,如果不去除页眉和固定法律用语,模型可能学到排版而不是经营信息。

  • 数字统一映射适合词汇基线,却丢失『增长 3%』与『增长 30%』的强度差异。对业绩预测,应将数值抽取为独立结构化特征。

  • 中英混排、表格和 OCR 错字会破坏分词。应分层检查原文、清洗文本、词元和稀疏特征,而不只检查最终矩阵维度。

  • 高 TF–IDF 是语料库内相对稀有性,不等于经济重要性、情感强度或因果影响。

17.8 本章小结

一个可复现的中文金融文本流程应保留文件血缘,将可验证的正则结构与语义清洗分开,再经过领域词典、停用词和稀疏向量化。BoW 描述词频,TF–IDF 在其上使用 式 17.2 的平滑 IDF 和 式 17.4 的默认 L2 归一化。检查结果时应操作稀疏矩阵的非零元素或小型聚合向量,不对全特征矩阵做稠密化。

17.9 分层练习

17.9.1 基础题

练习 17.1

解析六个年报文件名,验证代码恰为六位数字、年份恰为 2024,且公司名非空。

完整解答

完整的命名组检查见 列表 17.6

parsed_filename_rows = []  # 收集每个已通过完整匹配的文件名
for report_path in report_paths:  # 逐一审计实际读取的本地年报
    filename_match = filename_pattern.fullmatch(report_path.name)  # 禁止部分字符串匹配蒙混过关
    assert filename_match is not None  # 验证完整命名语法
    parsed_fields = filename_match.groupdict()  # 取得三个命名捕获组
    assert parsed_fields['code'].isdigit() and len(parsed_fields['code']) == 6  # 验证证券代码格式
    assert parsed_fields['year'] == '2024' and bool(parsed_fields['company'].strip())  # 验证年份与公司名业务约束
    parsed_filename_rows.append(parsed_fields)  # 保留通过所有检查的元数据
pd.DataFrame(parsed_filename_rows)  # 展示可追溯的文件名解析结果
列表 17.6
code year company
0 600104 2024 上汽集团
1 002415 2024 海康威视
2 600276 2024 恒瑞医药
3 002230 2024 科大讯飞
4 002142 2024 宁波银行
5 601018 2024 宁波港

17.9.2 进阶题

练习 17.2

从 TF–IDF 词表中选一个 \(df=2\) 的词,用 式 17.2 手工计算 IDF,并与 sklearn 属性比较。

完整解答

手工 IDF 对照见 表 17.9

candidate_positions = np.flatnonzero(document_frequency == 2)  # 定位恰好出现于两份年报的词
assert len(candidate_positions) > 0  # 确保当前真实语料存在符合题意的词
selected_position = int(candidate_positions[0])  # 以固定词表顺序选取可复现的示例
selected_term = tfidf_terms[selected_position]  # 将列位置映射为中文词项
selected_manual_idf = np.log((1 + len(tokenized_report_texts)) / (1 + 2)) + 1  # 以 N=6 且 df=2 手工实现平滑公式
solution_idf_table = pd.DataFrame({'词项': [selected_term], 'N': [len(tokenized_report_texts)], 'df': [2], '手工 IDF': [selected_manual_idf], 'sklearn IDF': [tfidf_vectorizer.idf_[selected_position]], '绝对差': [abs(selected_manual_idf - tfidf_vectorizer.idf_[selected_position])]})  # 对照公式输入、输出与数值误差
solution_idf_table.round(6)  # 显示两种计算在浮点精度下一致
表 17.9: 练习 17.2 完整解答
词项 N df 手工 IDF sklearn IDF 绝对差
0 API 6 2 1.847298 1.847298 0.0

17.9.3 真实数据应用题

练习 17.3

提取宁波银行年报的前 15 个 TF–IDF 词,报告该行非零元素数,并证明过程没有构造完整稠密特征矩阵。

完整解答

不稠密化的宁波银行结果见 表 17.10

from scipy import sparse  # 检查文本特征矩阵的存储布局
bank_document_position = int(report_metadata.index[report_metadata['company'].eq('宁波银行')][0])  # 依公司元数据定位银行年报
bank_sparse_row = tfidf_feature_matrix.getrow(bank_document_position)  # 仅取得宁波银行的 CSR 行
assert sparse.issparse(tfidf_feature_matrix) and sparse.issparse(bank_sparse_row)  # 验证全流程仍保持稀疏存储
bank_top_terms = extract_sparse_top_terms(tfidf_feature_matrix, tfidf_terms, bank_document_position, top_count=15)  # 只对银行行的非零权重排序
bank_top_terms['文档非零词数'] = bank_sparse_row.nnz  # 披露该文档在词表中的非零维数
bank_top_terms.round(4)  # 显示高权重词与稀疏性审计结果
表 17.10: 练习 17.3 的宁波银行稀疏高权重词
词项 权重 文档非零词数
0 宁波银行 0.6948 1079
1 百万元 0.5515 1079
2 垫款 0.1897 1079
3 宁波 0.1529 1079
4 宁波市 0.1333 1079
5 发放贷款 0.1043 1079
6 中央银行 0.0711 1079
7 先生 0.0683 1079
8 雅戈尔 0.0677 1079
9 充足率 0.0634 1079
10 一级 0.0609 1079
11 金融机构 0.0587 1079
12 金融服务 0.0585 1079
13 出生 0.0546 1079
14 买入 0.0537 1079

17.9.4 综合挑战题

练习 17.4

将基线 TF–IDF 扩展为一元和二元词组,但将词表上限保持为 2500。比较非零元素数和密度,并解释这一变化能解决什么、不能解决什么。

完整解答

两种词组设定的实测稀疏性见 表 17.11

ngram_vectorizer = TfidfVectorizer(tokenizer=str.split, token_pattern=None, lowercase=False, ngram_range=(1, 2), min_df=2, max_df=0.95, max_features=2500, smooth_idf=True, norm='l2')  # 在同一词表上限下加入相邻词序信息
ngram_feature_matrix = ngram_vectorizer.fit_transform(tokenized_report_texts)  # 直接产生不稠密化的 n-gram CSR 矩阵
baseline_density = tfidf_feature_matrix.nnz / np.prod(tfidf_feature_matrix.shape)  # 计算一元词基线的非零比例
ngram_density = ngram_feature_matrix.nnz / np.prod(ngram_feature_matrix.shape)  # 计算加入局部词序后的非零比例
ngram_comparison = pd.DataFrame({'特征集': ['一元词', '一元+二元词'], '词汇数': [tfidf_feature_matrix.shape[1], ngram_feature_matrix.shape[1]], '非零元素': [tfidf_feature_matrix.nnz, ngram_feature_matrix.nnz], '密度': [baseline_density, ngram_density]})  # 将局部语序收益与资源代价放在同一表中
ngram_comparison.round(5)  # 报告当前真实语料下的实际稀疏性
表 17.11: 练习 17.4 的一元与二元词组稀疏性比较
特征集 词汇数 非零元素 密度
0 一元词 2500 8693 0.57953
1 一元+二元词 2500 9853 0.65687

二元词可保留『重大 风险』、『研发 投入』等相邻语境,却仍无法理解跨句否定、长距离指代和篇章结构。词表上限固定时,新增二元词还会挤出部分一元词,因此应在时间或公司分组的交叉验证中评估,不能仅根据词表变大宣称改进。

17.9.5 边界审计题

练习 17.5

构造一句同时包含公司名、金融专名、模板停用词和否定词的短句,验证自定义词典保持『宁波银行』与『不良贷款率』完整、停用词被删除而『不』被保留。再分别用年报清洗器处理年报片段、用日志正则解析带时区日志;解释为什么不能交换两套规则。

完整解答

表 17.12 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。

tokenization_probe = '宁波银行 的 不 不良贷款率'  # 同时放入专名、模板词与独立否定词
probe_tokens = tokenize_chinese_text(tokenization_probe).split()  # 复用本章自定义词典和停用词策略
assert {'宁波银行', '不良贷款率', '不'}.issubset(probe_tokens)  # 验证公司名、金融专名和否定语义均被保留
assert '的' not in probe_tokens  # 验证任务相关停用词确已删除
annual_report_probe = clean_annual_report('2024 年研发投入为 12.5%。')  # 对长文档应用页码、URL与数值占位规则
log_probe = '2024-12-31T23:59:59+08:00 ERROR risk_engine exposure_limit_exceeded'  # 构造字段边界明确的带时区日志
log_match = log_record_pattern.fullmatch(log_probe)  # 对日志保留时间、级别、模块和消息四个字段
assert log_match is not None and log_match.group('level') == 'ERROR'  # 验证结构化日志解析成功
boundary_audit = pd.DataFrame({'对象': ['年报片段', '日志记录'], '处理结果': [annual_report_probe, str(log_match.groupdict())], '专用边界': ['正文语义清洗', '命名字段解析']})  # 并列展示两类文本的不同输出合同
boundary_audit  # 输出可人工复核的规则选择证据
表 17.12: 练习 17.5 的分词与文本类型边界审计
对象 处理结果 专用边界
0 年报片段 数值 年研发投入为 数值 正文语义清洗
1 日志记录 {'timestamp': '2024-12-31T23:59:59+08:00', 'le... 命名字段解析

年报清洗器把具体数字归一为语义占位词,适合降低长文档词汇稀疏性;若用于日志,会破坏时间戳、级别和模块边界。日志正则要求整行字段语法,若用于年报正文则几乎全部不匹配。两者必须先识别文本类型,再各自清洗或解析。