AI技术 · 2026-08-14

可复现的文本自变量

用 BERT 为经济研究提取稳定特征:从模型选型到特征入库的完整流程

把政策文本、年报、专利摘要放进回归方程之前,先要回答一个问题:这个数是哪来的,能不能算出第二遍。这篇文章讲清楚为什么生成式大模型过不了这一关,并把 BERT 特征提取的全流程完整走一遍——所有命令与输出都来自本机真实运行。

经济研究正在经历一个安静的转变:文本成了自变量。监管政策的一句话松紧、管理层讨论与分析的语调、专利摘要的技术密度,都被证实携带与因变量相关的信息。做法大同小异——把文本变成向量,再把向量变成回归里的一列或若干列。问题出在这一步的可靠性上:文本向量化如果是黑箱,后面所有的系数都建立在流沙上。

审稿人重跑你的代码,得到另一个向量、另一组系数,研究就垮了。所以"可复现"不是工程洁癖,是文本进入因果分析的准入证。这篇文章讨论三件事:为什么随手可用的生成式大模型过不了复现这一关;用 BERT 做特征提取的完整流程怎么走;以及一个以专利摘要为例、从原始文本到 MySQL 特征表的真实演示。

一、为什么生成式大模型做不好这件事

先看我们习以为常的用法:把文本丢给一个大模型,"帮我总结成十个维度打分"或"输出这段话的向量"。方便,但把研究押上去之前,得看清它的工作方式。生成式大模型是动态对齐的——输出取决于当下的上下文窗口、系统提示、对话历史,甚至前几轮你问了什么。同一个"该政策显著收紧了流动性",单独问和放在一堆货币政策文本里问,得到的判断可以不同。

三个不稳定来源叠加。其一,上下文依赖:换一个批次、换一次文件分组,结果就变。其二,采样随机:带温度的生成过程本身就是抽签,同一提问跑两遍答案微差。其三,版本漂移:服务商静默更新权重,今天跑通的脚本下个月输出就变了,而且没有任何报错提示。

什么是动态对齐:生成式大模型在生成每个词之前,会先把当前对话的上下文、系统提示、历史记录连同你的输入一起"对齐"成一个内部状态,再据此采样下一个词。所以它的输出不是输入的确定性函数,而是输入 + 全部上下文 + 随机采样三者的函数——上下文变了、提示变了、随机数变了,答案就跟着变。这跟"拿固定仪器测同一个量"是两回事。

对照之下,BERT 一类双向编码器模型是另一种物种。它的权重一旦下载就是死的:没有采样,没有对话状态,一段文本做一次确定性前向传播,输出由权重和输入唯一决定。同一段专利摘要,今天算和下个月算,向量逐位相同。文本自变量需要的就是这个性质——不是更聪明,而是更死板。

什么是 BERT:全称 Bidirectional Encoder Representations from Transformers(2019 年由 Google 提出)。它用海量文本做"掩码语言模型"预训练——随机遮住句子里的词,让模型凭上下文猜回来,从而学会"双向"地理解每个词的语境。训练完成后它有两个本事:一是把一整段文本压成一个固定维度的向量(默认 768 维),二是这个向量能当作下游任务(分类、相似度、特征提取)的输入。对本文而言,我们只用它第一个本事:当一台确定性的文本编码器。

生成式大模型BERT 编码器
输出决定因素上下文、提示、采样、版本权重 + 输入文本
同输入重复运行结果可漂移逐位一致
权重可控性服务端随时更新本地文件,永久冻结
适合的角色探索、起草、辅助判断进入回归的测量仪器

分工可以这样记:大模型当研究员用,帮你读文献、提假设;BERT 当仪器用,负责把文本变成数。仪器的第一条规矩是刻度稳定,谁家的仪器每次读数都变,测出来的"因果关系"就无从谈起。

二、流程总览与模型选型

整条流水线五步:选型并本地化模型 → 固定环境 → 预处理 → 提取最后四层平均特征 → 入库 MySQL 供实证调用。先说选型,这里最容易踩坑。

选型三问:语言、领域、版本。中文文本用中文预训练模型,这是第一道筛;领域对得上更好——金融经济文本有在财经语料上继续预训练的模型,学术文本有科学语料版本;最后是版本,也是被忽略最深的一坑:同名模型不等于同一份权重。模型仓库会更新,更新后你的向量就变了。做法是把模型完整下载到本地目录,从此只从本地路径加载,绝不线上实时拉取。

魔搭社区(ModelScope)是什么:阿里达摩院主导的国内模型开源社区,相当于中文语境下的模型仓库——BERT、Qwen 系列、各类中文领域模型都托管在上面,注册后可以直接浏览、下载、在线试用,多数模型免费。对做中文经济文本的同学,它比从境外源拉模型更稳、更快,也更好找中文金融/学术语料微调过的模型。

魔搭社区模型列表页截图,展示可浏览下载的中文模型
图 1 · 魔搭社区模型页(modelscope.cn/models):按名称、领域、任务筛选模型,右上角模型卡片点击进入详情页。

以魔搭为例,BERT 家族里值得知道的几类:通用中文的 StructBERT 系列与 bert-base-chinese;金融领域的孟子 BERT-Fin 等;学术文本的 SciBERT。它们的差别在预训练语料,选择标准就是你的文本长什么样。本文演示用的是最经典的 bert-base-chinese——12 层 Transformer,隐藏维 768,中文语料预训练,作为流程载体足够,实际研究中按领域替换即可。它的详情页长这样:

魔搭社区 bert-base-chinese 模型详情页截图
图 2 · bert-base-chinese 的模型详情页(modelscope.cn/models/AI-ModelScope/bert-base-chinese):页面上有模型介绍、文件列表与模型 ID,下载命令用这个 ID。

环境配置

虚拟环境与依赖锁定的完整讲法,在 《Python 在金融中的应用》课程的环境章节里有展开,这里只给清单:独立环境里装 torchtransformerssqlalchemypymysql,用 pip freeze 把版本写死。模型用魔搭的 snapshot_download 拉到本地:

# 从魔搭社区下载模型到本地(一次即可,之后全部离线加载)
from modelscope import snapshot_download
model_dir = snapshot_download('AI-ModelScope/bert-base-chinese', cache_dir='./models')
# model_dir 指向本地快照目录,把它记进论文的复现文档

三、特征提取:为什么取最后四层平均

这一步是全文的技术核心。BERT 有 12 层,每层对同一段文本各给出一个表示,取哪层、怎么合并,决定了你拿到的特征是什么。下面一步一步把"为什么是最后四层平均"讲清楚。

3.1 BERT 的 12 层是什么

bert-base-chinese 内部是 12 个首尾相接的 Transformer 编码块。一段文本进来,先被分词器切成一个个词元(token),每个词元拿到一个初始向量,然后依次穿过这 12 层——每过一层,向量就被"加工"一次,融入更多的上下文信息。第 1 层的向量还很"原始",第 12 层的向量已经是看过整段文本之后的深度表示。

3.2 层与层是有分工的

可解释性研究反复观察到一个稳定的规律:层是有分工的。越靠底层,向量越像在做"语法"——词性、搭配、句子结构;越靠顶层,越像在做"语义"——主题、领域、整段话在讲什么。可以这样粗略划带:

层带大致层号主要编码内容对经济文本的意义
底层1–4词性、句法、短语搭配通用,所有文本都差不多
中层5–8局部语义、事件结构"谁对谁做了什么"这类关系
高层9–12主题、领域、抽象概念专利的技术组合方式、政策的立场强度

我们的目标是拿文本当自变量做因果分析,关心的恰恰是"这段话在讲什么"——技术密度、政策松紧、立场强度——这些信息集中在中高层。这决定了取值要偏向靠后的层。

3.3 为什么不用第一层,也不用最后一层

两个极端都有毛病。取第一层:向量还在处理语法,几乎没吸收整段文本的主题信息,拿它当"文本内容"的代表会漏掉核心语义。取最后一层:问题更隐蔽——BERT 的预训练任务是"掩码语言模型"(遮住一个词猜它),最后一层被训练得最贴合"预测被遮词"这个目标,而不是"给出一个通用的文本表示",所以单取第 12 层往往偏科,且单层信息方差大、不够稳定。

换句话说:最后一层不是不好,是它太"应试"——为预训练任务优化过头,反而未必是最适合下游通用用途的一层。

3.4 为什么恰好取四层,而不是一层或全部

确定"取靠后的若干层"之后,还有三个候选,可以摆出来比较:

取法优点缺点结论
只取最后 1 层最省、最贴合微调场景偏科预训练目标、单层方差大分类可用,特征提取偏弱
全部 12 层平均信息最全混入底层语法噪声,稀释高层语义;计算翻倍信息全但"信噪比"下降
最后 4 层平均覆盖中高层语义梯度,平滑单层方差多一步平均,几乎无额外成本特征提取的稳健默认

"四层"不是一个玄学数字,是一组权衡的结果:取少了信息单薄、方差大,取多了把底层语法噪声也搅进来。最后四层恰好落在"高层语义为主、仍保留中层事件结构"的区间,四层平均又抹平了单层的随机波动——这就是它在 NLP 实践中成为默认做法、也被我们的一项工作论文(条件马氏语义分数,CMSS)采用为专利摘要主表示的原因。

3.5 为什么取 CLS 位置

BERT 每层对一段文本输出的是一串向量——每个词元一个,一段 128 词的摘要就是 128 个向量。但我们要的是"整段文本"的一个向量,取谁?答案是每个序列开头的特殊词元 [CLS]。BERT 的设计里,[CLS] 位置专门用来汇总整段文本的信息——它的向量"看过"所有词,是整段的代表。所以提取时固定取每层第 0 个位置(CLS),而不是把 128 个词元向量平均。

3.6 一步步操作

拆成四步,对应代码里的四行:

  1. 要求模型输出全部隐藏状态。output_hidden_states=True,模型会返回 13 组向量——初始嵌入层 + 12 个编码层。
  2. 取最后四层。hidden_states[-4:] 切片,拿到第 9–12 层的表示。
  3. 每层只取 CLS。定位每层第 0 个位置,得到 4 个 768 维向量。
  4. 四层求平均。沿层维度做算术平均,得到 1 个 768 维向量——这就是这篇文本的特征。
# 预处理:参数写死
enc = tokenizer(texts, return_tensors="pt",
                padding="max_length", truncation=True, max_length=128)

with torch.no_grad():                        # 关闭梯度,只做前向
    out = model(**enc, output_hidden_states=True)
    # out.hidden_states: 13 组 (embedding + 12 层),每组 [B, L, 768]

cls = torch.stack(out.hidden_states[-4:], dim=1)   # 取最后4层 → [B, 4, L, 768]
cls_vec = cls[:, :, 0, :]                            # 每层取 CLS(位置0) → [B, 4, 768]
feature = cls_vec.mean(dim=1)                        # 四层平均 → [B, 768]

三个固定动作贯穿始终:padding="max_length"max_length=128 写死(本演示摘要较短,用 128;长文本 512);model.eval() 关闭 dropout;torch.no_grad() 关闭梯度。加上 random / numpy / torch 三处种子固定,这四件事做完,提取过程就没有任何自由度了。分类任务可以只取最后一层 CLS 接分类头,那样最省;做特征提取进回归,四层平均是更稳妥的默认。

四、为什么入库 MySQL,而不是 CSV

特征算出来存哪?CSV 和 DTA 是常见选择,但对 768 维浮点向量都不友好:一篇文本一行、一行三千多个数字,文件膨胀快,读取慢,而且没有类型约束——float32 存成文本再读回来,精度损失是悄悄发生的。关系型数据库把这些问题一次解决:LONGBLOB 存二进制,float32 原样进、原样出,一字节不差;主键可索引、可增量追加、可被 Stata、Python、R 同时按需查询。数据量到几万篇专利时,差距是分钟与秒的差别。

# SQLAlchemy 建表 + 写入(表结构随博文发布)
CREATE TABLE patent_features (
    pid   VARCHAR(8)  PRIMARY KEY,      # 文本编号
    field VARCHAR(16),                  # 领域标签
    dim   SMALLINT,                     # 特征维度(768)
    feat  LONGBLOB,                     # float32 字节流,768×4=3072 字节
    model_tag VARCHAR(64)               # 模型版本标签,复现的钥匙
)

feat_bytes = feature.numpy().astype(np.float32).tobytes()
# 读回时:np.frombuffer(row.feat, dtype=np.float32)

model_tag 这一列值得多说一句:它记录特征由哪个模型、哪个版本算出。三年后回看数据库,你还能确切知道每个数是谁生产的——这是复现文档的数据库化。

五、实战演示:六篇专利摘要走完全程

演示数据是六篇不同领域的示例专利摘要(锂电池、污水处理、图像识别、包装材料、光伏、无人机),每篇约八十字。下面是三个脚本依次执行的完整记录(本机真实运行,macOS,CPU)。全部代码可下载使用:三个脚本连同说明放在 code/ 目录extract_store.pyverify_repro.pydownstream_demo.pyREADME.md),按 README 装依赖、改好模型目录与数据库连接即可复现。

第一步:提取特征并入库(extract_store.py)

这个脚本按顺序做五件事,输出里用 [n/5] 标了进度。逐一对应:

  1. 固定随机种子。random.seed(42)numpytorch 三处都设成 42——同一个数,确保任何两次运行从源头就一致。
  2. 从本地目录加载模型。不联网、不从线上拉权重,直接读本地快照;model.eval() 关掉 dropout。输出确认是 12 层、768 维。
  3. 准备好示例文本。六篇专利摘要连同编号与领域标签写死在脚本里,供你替换成自己的语料。
  4. 提取特征。按第三节的四层平均法,得到 6×768 的 float32 矩阵;输出里打印了 P1 前五维数值,肉眼可见是"有内容"的向量。
  5. 写入 MySQL。建表 patent_features,六行特征以二进制字节流写入 LONGBLOB,共 18,432 字节(6 × 768 × 4)。
终端运行记录:六篇专利摘要经 BERT 提取为 6×768 特征并写入 MySQL
图 3 · extract_store.py 的真实运行输出:五个进度标记与文字一一对应,最后 6 行、18,432 字节写入完成。

第二步:复现性验证(verify_repro.py)

这是全文最关键的一步——证明"同一文本永远同一向量"不是嘴上说说。脚本做了两层验证:

  1. 两次独立提取。run_once() 被调用两次,每次都重新加载模型(不是复用内存里的),完整重跑提取。然后逐位比较两次结果的最大绝对差。
  2. 与数据库读回比对。把 MySQL 里的字节流用 np.frombuffer 还原成向量,和内存里的结果逐元素比,确认"写进去的"和"读出来的"一字节不差。

结果:最大绝对差 0.0000000000,读回 6/6 字节级一致。这个"零"就是编码器模型给的可复现承诺——同样的权重、同样的输入、同样的参数,输出逐位相同,今天跑和三年后跑没有区别。

终端运行记录:两次独立提取最大绝对差为零,MySQL 读回六篇全部字节级一致
图 4 · verify_repro.py 的真实运行输出:两次独立运行(含重新加载模型)最大绝对差 0.0000000000;MySQL 读回 6/6 篇字节级一致。

第三步:下游演示(downstream_demo.py)

最后验证特征"能用"。从 MySQL 读回特征,做一件最常见的下游操作——算六篇摘要两两之间的余弦相似度:

  1. 读回特征。patent_features 表按主键读出,反序列化成 6×768 矩阵。
  2. L2 归一化。每个向量除以其模长,落到单位球面上——此时两向量内积就等于余弦相似度。
  3. 算相似度矩阵并排序。输出 6×6 对称矩阵,再找出最相似与最不相似的专利对。
终端运行记录:六篇专利摘要特征的余弦相似度矩阵与最相似专利对
图 5 · downstream_demo.py 的真实运行输出:余弦相似度矩阵,以及最相似(0.953)与最不相似(0.823)的专利对。

这个结果本身就是一课。矩阵里所有数值都挤在 0.82–0.95 之间、区分度弱,而且"污水处理"和"无人机"以 0.953 高居榜首——两个技术领域几乎不相干,余弦却认为它们最像。这不是 BERT 坏了,而是原始嵌入的各向异性:向量挤在空间的窄锥里,余弦量到的先是语言的公共套路,其次才是真正的语义差异。要把"站得多偏"量准,需要按协方差结构加权的距离,这正是本站马氏距离一文与 CMSS 工作论文处理的问题。特征提取管"稳定地表示",度量选择管"准确地比较",两步各司其职。

至于把 768 维接进回归:向量化均值、主成分、或直接用惩罚回归,都是常见路径;关键原则是特征生产与实证分析彻底解耦——数据库就是接口,换模型重跑提取,回归代码一行不改。

六、避坑清单

  1. 预处理参数写死。max_length、padding、truncation 全部显式声明,不依赖库的默认值——默认值会随版本变。
  2. 模型必须本地化。固定目录、固定版本、离线加载;论文附录里记下模型来源与目录结构。
  3. 种子固定三件套。random、numpy、torch 一个不能少;GPU 上还要处理确定性算法开关。
  4. 特征用二进制存。float32 走 LONGBLOB,别让 CSV 的文本序列化偷走精度;读回用 frombuffer 还原。
  5. 版本标签入库。model_tag 列记下特征由谁生产,数据库自己会说话。
  6. 交付前自测复现。像图 2 那样重跑一遍、逐位比对,零差异再交付。这一步花两分钟,省掉审稿阶段的百口莫辩。

写在最后

文本作为自变量进入因果分析,测量的可靠性决定结论的寿命。生成式大模型改变了研究的速度,但进入回归的那一步,仍然需要一台刻度不动的仪器:本地权重、固定参数、确定性前向、字节级可验。这套流程搭起来不过一个下午,换来的是别人任何时候重跑你的代码,得到的都是同一个数。

课程相关的环境配置与 Python 工具链,见《Python 在金融中的应用》;特征之后的度量问题,从马氏距离那篇接着读。

参考资料

  1. Devlin, J., Chang, M.-W., Lee, K., & Toutanova, K. (2019). BERT: Pre-training of Deep Bidirectional Transformers for Language Understanding. NAACL-HLT. — aclanthology.org/N19-1423
  2. 魔搭社区 ModelScope:模型下载与本地化. — modelscope.cn
  3. Hugging Face Transformers 官方文档. — huggingface.co/docs/transformers
  4. 本站课程《Python 在金融中的应用》:环境、依赖与工具链. — nihe.net.cn/teaching/python-finance
  5. 本站博文《马氏距离:把斜的空间拉直再量》. — nihe.net.cn/blog/2026/mahalanobis-distance
  6. Ni, H., & Guo, X. (2026). Semantic Atypicality, Technological Search, and Patent Outcomes(工作论文,CMSS 框架).