问题、先修与学习目标
年报、临时公告、问询函和系统日志都是文本,但它们不是同一种数据。年报是长文档,存在页眉、页码、目录、表格与固定法律表述;日志则通常有时间戳、级别、模块和消息的半结构化边界。文本特征工程的核心不是删掉越多符号越好,而是在保留业务语义的前提下,建立从原始字节到稀疏数值矩阵的可审计映射。
本章先修是 Python 字符串、集合和函数,正则表达式的字符类与捕获组,以及线性代数中向量范数和内积。学完后,读者应能够:
读取本地年报 Markdown,从文件名提取证券代码、年份和公司名;
区分年报清洗与日志解析,为数值、页码、URL 和字段边界设计可验证的正则模式;
使用 jieba 精确模式、金融自定义词和停用词完成中文分词;
从文档—词项矩阵定义 BoW,并严格按 sklearn 默认 smooth_idf=True 推导 TF–IDF;
在不将全特征矩阵稠密化的条件下检查非零元素、高权重词和内存占用;
解释 BoW/TF–IDF 的词序损失、词汇漂移、长文档偏差和拟合泄漏边界。
本章按下表把每个正式目标落到可观察的核心答案。
表 表 17.1 汇总本节的计算或审计结果,解释时应遵循正文给出的口径与限制。
本地年报语料与数据血缘
本章直接使用本地 annual_report/ 目录中的 2024 年年报 Markdown,样本公司分别来自上海、江苏、浙江和安徽。列表 17.1 固定并验证语料文件。选定的六家公司横跨汽车、智能物联、医药、软件、银行与港口,便于观察行业词汇的区分性。
表 17.2 把每个本地文件绑定到巨潮资讯的法定披露全文、披露日与本地字节哈希。六份 Markdown 的历史 PDF→Markdown 转换日志均没有随语料保存,因此转换工具和版本只能标为『未记录(不可证实)』;文件时间也只能证明本地副本在该时刻存在,不能倒推出下载或转换流程。这里明确暴露缺口,而不根据排版特征猜测工具。公开公告不等于开放许可:教材可链接官方 PDF,并在授权教学环境内分析本地副本,但不把 PDF 或 Markdown 全文打包进仓库或再分发。
六份文本合计不足 5 MB,因此普通渲染可直接验证其 SHA-256;数百 MB 至 GB 的行情文件则只在发布审计时做全量哈希,普通渲染使用 schema、行数与固定日期样本检查。两级策略既阻止语料静默漂移,也避免每次渲染顺序读取整套巨型行情。
用正则表达式解析元数据
文件名是数据血缘的一部分。模式 ^(?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 # 展示本章实际使用的真实语料血缘
公告与日志的正则边界
正则表达式应用于结构稳定、可明确验证的模式,不应试图一次性理解整份年报。两种文本需要不同思路:
年报中的 12/219 是页码,URL 是排版残留,百分比和金额则可能是金融信息。基线模型将数字统一映射为『数值』,而不是简单删除;
日志常见语法是『ISO 时间戳—级别—模块—消息』。解析模式需要锚定行首,并将消息保留为最后一个宽字段,否则消息内部的空格会被误拆;
多行堆栈跟踪不能逐行独立解析。它们必须先根据新时间戳的行首识别记录边界,再把续行归并到上一条记录。
两类文本的可复用模式统一列于 列表 17.2。
日志模式没有被强行用到年报上,因为那会将两种不同数据语法混为一谈。生产日志还应对时间戳使用 Asia/Shanghai 或明确时区偏移做语义验证,不能只因字符串匹配就认为时间正确。年报专用清洗器见 列表 17.3。
中文分词、词典与停用词
中文没有天然词空格。jieba 基于词典构建有向无环图,再寻找总词频概率较高的分词路径;未登录词还会借助统计模型识别。自定义词典的作用不是『提高所有分词准确率』,而是显式保护『海康威视』、『不良贷款率』这类业务完整词;领域词典设置见 列表 17.4。
停用词过滤是任务依赖的。『不』在一般词表中可能是停用词,但在『不存在重大遗漏』中改变了句子极性,因此本章不删除否定词。只保留长度大于 1 的词是教学基线,它会丢失『碳』、『铝』等单字行业词,正式项目应依词表保留而不应一刀切。六份年报的处理规模见 表 17.4。
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 # 展示清洗和分词是否导致异常信息损失
词袋模型与稀疏矩阵
设语料库有 \(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) # 先展示稀疏性与维度概况
top_count_table # 展示从稀疏矩阵的列聚合中得到的高频词
表 17.5 报告矩阵维度与稀疏性,表 17.6 展示列聚合后的高频词。min_df=2 排除只在一份年报出现的词,可以抑制 OCR 残留,但也可能删除真正独特的业务风险词。max_df=0.95 在六篇文档中意味着出现于全部六篇的词会被排除。这些阈值必须根据语料规模披露,不是通用最优值。
TF–IDF:与 sklearn 默认实现完全一致
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 一致性
不稠密化的高权重词检查
整个 \(N×p\) 特征矩阵只保留非零元素。为查看某份年报,只需取得一个稀疏行的 indices 和 data,再对该行的非零权重排序。列表 17.5 封装了这一 top-k 操作,避免对全矩阵调用 .toarray()。
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 中的词是『在这六份文档的当前预处理和词表下,对该公司相对突出』,不是管理层认定的重要性,也不是投资建议。词项排名会随语料库、停用词、min_df 和分词词典改变。
BoW 与 TF–IDF 的比较和适用边界
| 基本数值 |
原始非负词频 |
词频×平滑 IDF,默认再做 L2 归一化 |
| 高频共性词 |
可能支配文档长度 |
IDF 较低,权重被抑制 |
| 文档长度 |
计数随长度增大 |
L2 归一化降低尺度差异 |
| 可解释性 |
直接解释为出现次数 |
解释为语料条件下的相对权重 |
| 典型任务 |
词频统计、朴素贝叶基线 |
信息检索、线性分类、相似度基线 |
样本切分与词汇泄漏
fit 不只计算 IDF,还决定词表、文档频率阈值和高频截断。在文本分类中,向量化器必须位于 Pipeline 内部并在每个训练折重新拟合。年报预测还应按披露时间切分,不能让未来年度新出现的业务词提前进入词表。同一公司跨年文档高度相似,若要评估跨公司泛化,还需按公司分组。
方法失效的场景
BoW 与 TF–IDF 忽略长距离语序和篇章结构,对否定、转折、指代和跨段落风险描述的理解有限。
年报模板大量重复,如果不去除页眉和固定法律用语,模型可能学到排版而不是经营信息。
数字统一映射适合词汇基线,却丢失『增长 3%』与『增长 30%』的强度差异。对业绩预测,应将数值抽取为独立结构化特征。
中英混排、表格和 OCR 错字会破坏分词。应分层检查原文、清洗文本、词元和稀疏特征,而不只检查最终矩阵维度。
高 TF–IDF 是语料库内相对稀有性,不等于经济重要性、情感强度或因果影响。
本章小结
一个可复现的中文金融文本流程应保留文件血缘,将可验证的正则结构与语义清洗分开,再经过领域词典、停用词和稀疏向量化。BoW 描述词频,TF–IDF 在其上使用 式 17.2 的平滑 IDF 和 式 17.4 的默认 L2 归一化。检查结果时应操作稀疏矩阵的非零元素或小型聚合向量,不对全特征矩阵做稠密化。
分层练习
基础题
练习 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.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.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.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.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 # 输出可人工复核的规则选择证据
年报清洗器把具体数字归一为语义占位词,适合降低长文档词汇稀疏性;若用于日志,会破坏时间戳、级别和模块边界。日志正则要求整行字段语法,若用于年报正文则几乎全部不匹配。两者必须先识别文本类型,再各自清洗或解析。