最近经管圈讨论比较多的一件事,是顶刊开始接受并鼓励研究者用大语言模型来处理经济文本数据了。金星晔等(2024)发表在《经济研究》上的那篇论文,核心落点就是这样一个方法论:用BERT这类预训练语言模型对经济文本做分类,把过去靠人工读资料、手打标签的活交给模型来干。这个方向在学术界和业界都很有代表性,所以我自己动手把它完整复现了一遍。
复现这套方法需要的技术栈,恰好是Python + Pytorch + BERT微调。整套流程跑下来我的感受是:对经管类研究者来说,这是当前最值得掌握的一条“文本即数据”路径,门槛比想象中低,但收益非常直接。这篇文章就从复现的角度,把环境搭建、数据准备、模型微调、问题排查一步步展开,适合想系统学习文本分类和预训练模型的经济管理方向学生、青年教师,也适合刚入坑NLP、想找一个真实研究场景练手的同学。
1. 复现目标拆解:经管论文里的大语言模型到底怎么用
1.1 论文方法的技术实质
很多第一次接触这类复现项目的同学,一看到“大语言模型”几个字,脑子里浮现的可能是ChatGPT那种能聊天的界面。但从技术实现层面看,这篇《经济研究》论文落到代码上的核心东西,其实是一条非常清晰的文本分类流水线:
原始文本(年报、财经新闻、政策条文等)→ 文本清洗 → BERT编码器 → 分类头 → 预测标签
这套流水线的本质,是让模型把一段自然语言转换成结构化的类别标识,比如判断某条新闻的情绪倾向是正面、负面还是中性。在经济学和管理学研究里,这类工作极其常见:测算投资者情绪、识别企业信息披露语调、给政策文件做主题编码,本质上都在做同一件事。
这里需要澄清一个概念。大语言模型并非只有GPT那一类生成式模型,BERT属于大语言模型家族中偏向“理解”的编码器分支。GPT擅长续写和对话,BERT擅长判断句子里的关系、给文本分类。做分类任务时,BERT这类双向编码模型在效果、训练成本、可复现性上都有明显优势,这也是金星晔等(2024)那篇论文选择BERT路径的根本原因。
1.2 为什么要微调而不是直接调API
我复现之前专门比较过两条路线:微调开源BERT模型,或者接外部大模型API让模型直接输出标签。两条路在论文场景下的差异非常明显。
先算一笔账。假设你手头有10万条财经新闻,平均每条200字。调用外部API按token收费,中文一个字大概相当于1到2个token,10万条文本的成本可能是数千甚至上万元。如果换成在本地微调BERT,训练阶段用一块普通GPU跑几个小时,之后推理阶段基本是零成本。
成本之外还有可复现性问题。API背后的模型版本会升级、参数会被厂商调整,你今天调用输出的结果,几个月后可能就变了。论文审稿人要求复现你的结果时,你自己都不一定能原样跑出来。微调一个开源的BERT模型就完全没有这个问题,模型权重固定在自己手里,跑十遍结果都一样。
再说数据安全。经管研究中经常涉及上市公司未公开信息、企业调研数据、内部资料,这些数据直接传到外部API接口,存在不小的合规风险。用本地微调方案,数据从头到尾不出服务器,没有这些顾虑。综合来看,在正式的经管论文研究里,微调BERT几乎是性价比最高的方案。
1.3 复现的技术路线图
我复现时的完整技术路线分五步:
- 环境准备:Anaconda创建独立虚拟环境,安装Python 3.9、PyTorch和transformers库。
- 数据处理:读取经管文本数据,清洗、编码标签,构造训练集和验证集。
- 模型加载:用HuggingFace transformers加载中文BERT预训练模型和对应分词器。
- 微调训练:配置AdamW优化器、学习率调度器,完成若干个epoch的训练。
- 评估分析:计算准确率、macro-F1,输出分类报告,和论文结果进行对照。
整条链路核心代码大概200行出头。我强烈建议经管背景的同学把这个项目作为自己深度学习的第一个完整复现项目,因为它能让你在最短时间内看到“预训练模型解决实际研究任务”的全过程,而不是停留在调包的层面。
2. 环境搭建:Python+Pytorch+BERT要踩的几个环节
2.1 用Anaconda隔离Python环境
环境问题是我见过劝退最多人的第一道坎。很多同学直接在自己电脑默认Python环境里pip install torch,装完发现某个包把另一个包依赖搞坏了,或者torch版本与CUDA对不上,一跑就报错。
我复现项目时使用的做法是Anaconda建独立虚拟环境:
conda create -n econllm python=3.9 -y conda activate econllm pip install torch torchvision pip install transformers datasets scikit-learn pandasPython版本选3.9是我踩过好几次坑之后固化的选择。transformers和torch对3.9的兼容性目前维护得最好,各种第三方辅助库也比较稳。不要一上来就装Python 3.12甚至3.13,否则你会发现自己稀里糊涂地踩进“版本太新导致某个老依赖装不上”的坑里,非常浪费时间。
2.2 CPU还是GPU,显存需要多大
复现这个项目,GPU不是必须的,但有和没有的体验完全不同。BERT-base中文模型的参数量是1.02亿,FP32精度下权重文件大约410MB。在max_len=128、batch_size=16的配置下,6GB显存就能顺畅训练。没有GPU也能跑,但速度差距很直观:同样一个epoch,在8核CPU上可能要跑40分钟,换到入门级GPU可能只要3分钟。
装好环境后,用下面这段代码确认GPU是否就绪:
import torch print(torch.__version__) print(torch.cuda.is_available()) print(torch.cuda.get_device_name(0) if torch.cuda.is_available() else "CPU mode")看到torch.cuda.is_available()为True就说明CUDA环境正常。如果为False,多半是PyTorch装成了CPU版本,需要去PyTorch官网根据CUDA版本重新安装。
2.3 加载BERT模型时的隐藏知识点
日常做BERT项目基本离不开HuggingFace的transformers库。加载中文BERT模型的代码只要三行:
from transformers import AutoTokenizer, AutoModelForSequenceClassification model_name = "bert-base-chinese" tokenizer = AutoTokenizer.from_pretrained(model_name) model = AutoModelForSequenceClassification.from_pretrained(model_name, num_labels=3)这里有个对中文任务很关键的设计:bert-base-chinese使用字级词表,共21128个token,按单字切分中文文本,不需要先做中文分词,直接输入连续字符即可。这个特性让中文BERT开箱即用,也避免了分词错误传导到下游任务的问题。
首次运行时会自动下载预训练权重,约400MB,国内网络可能比较慢。下载完成后HuggingFace会缓存到本地的~/.cache/huggingface目录里,后续加载不会再重复下载。如果下载经常失败,可以配置镜像源解决,或者用hf-mirror这类社区维护的镜像域名。
3. 数据准备:经管类文本分类任务的建模思路
3.1 从论文任务到可训练的数据集
复现论文时,最忌讳一上来就追求和原文一模一样的数据。论文使用的很多数据是研究者自己整理、清洗、标注的,公开渠道不一定拿得到。我的做法是:先构造一个典型的经管文本分类任务,把方法链路完整跑通。
我复现时采用的设定是财经新闻情感三分类,标签为“正面”“负面”“中性”。这类任务在经管研究里出现频率极高,无论是测算媒体报道倾向、分析上市公司公告语调,还是构建投资者情绪指标,本质上都是同一个问题。任务背景抽离之后,模型层面做的事情完全一致。
训练数据的基本格式长这样:
| 文本 | 标签 |
|---|---|
| 公司前三季度净利润同比增长32%,营收创历史新高 | 正面 |
| 某企业因环境污染问题被环保部门罚款150万元 | 负面 |
| 公司召开董事会审议年度报告相关议案 | 中性 |
标签列可以直接用中文字符串,训练时再映射成0、1、2的整数索引。构造数据集时,类别之间的样本量不要差距太大,否则模型会严重偏向多数类。我的经验是每类至少准备1000条以上,数据太少的话BERT很难充分微调。
3.2 经管文本清洗的尺度把握
经管文本和通用文本不一样,清洗时有很多看起来“该做”但实际会伤信息的操作。我在第一次复现时就走过弯路,把文本里的数字、百分号全删了,结果模型情感判断能力明显下降。后来想明白,经管语句里“利润增长12.5%”“罚款150万元”这些数字恰恰是判断情感的核心信号。
我的清洗原则有三条:
- 连续空白符统一替换成单空格,删除无意义的换行符。
- 保留数字、百分号、货币符号,这些是经管文本的关键语义载体。
- 不做过度的停用词过滤。BERT的字级编码机制和传统TF-IDF完全不同,它依赖上下文理解语义,显式删停用词反而会破坏句子的完整信息。
清洗代码保持简单即可:
import re def clean_text(text): text = re.sub(r'\s+', ' ', str(text)) return text.strip()清洗阶段做得太多“花活”,往往是在给自己埋坑。
3.3 数据划分与评估指标选择
经管数据很多时候带有时间顺序。比如按时间排列的新闻文本,如果直接在随机划分的训练集和测试集上评估,可能会出现“用未来信息预测过去”的泄漏问题。正确的做法是先按时间切分,比如用前80%时间段的样本做训练,后20%做验证。
如果样本是独立的截面数据,没有明显时间依赖,直接用train_test_split做随机划分即可。无论哪种划分方式,都要确保训练集和测试集没有重复样本。
评估指标方面,类别均衡时直接用accuracy就行。但经管文本经常出现类别不均衡,比如财经新闻里“中性”占比过半,这时候建议使用macro-F1,也就是每个类别的F1分别计算后取平均,避免指标被大头类别撑起来。scikit-learn里这两个指标都是现成的:
from sklearn.metrics import accuracy_score, classification_report3.4 长文本如何处理
BERT-base的最大输入长度限制是512个token,中文条件下大约是512个字。很多经管文本远超这个长度,一份上市公司年报少说几万字,不可能整体塞进模型。
对这个问题,我没有选择无脑截断,而是换了一种处理思路。对于长文档情感分类,先按句切分,逐句预测情感标签,再把各句情感汇总成整篇文档的情感倾向,比如采用多数投票或按比例加权。这样能最大程度保留长文本信息。实际效果比直接截断前512个字要好,尤其适合公告、研报这类逻辑层次分明的文本。
如果只是短文本分类任务,直接截断到128或者200就足够了,没必要做复杂方案。
4. 微调BERT的核心实现细节
4.1 Dataset类与DataLoader的动态填充
PyTorch训练的第一步是写Dataset类。我的实现如下:
from torch.utils.data import Dataset import torch class TextDataset(Dataset): def __init__(self, texts, labels, tokenizer, max_len=128): self.texts = texts self.labels = labels self.tokenizer = tokenizer self.max_len = max_len def __len__(self): return len(self.texts) def __getitem__(self, idx): text = self.texts[idx] inputs = self.tokenizer( text, truncation=True, padding="max_length", max_length=self.max_len ) return { "input_ids": torch.tensor(inputs["input_ids"], dtype=torch.long), "attention_mask": torch.tensor(inputs["attention_mask"], dtype=torch.long), "labels": torch.tensor(self.labels[idx], dtype=torch.long) }这段代码有两个地方需要解释一下。padding="max_length"指的是把所有样本都填充到统一长度128,每个batch的数据形状完全一致,处理方式最简单。但这种做法会浪费显存,因为短句被强行填充了一堆无意义的[PAD]标记。批量运行时,更高效的做法是padding=True,配合DataLoader的collate_fn做动态填充,只把当前batch内的样本对齐到同一长度。数据量大时显存能省三成以上。
4.2 分类头是谁加的
用AutoModelForSequenceClassification加载模型时,transformers会在BERT的编码器输出之上自动添加一个线性分类层,把[CLS]位置的768维语义向量投影到我们的类别数量上。这个设计是现成的,不需要自己写。
但理解背后的机制很重要。BERT把每个位置的汉字转换成一个768维的向量,其中[CLS]这个特殊位置的向量可以视为整个句子的语义压缩表示。分类头的任务,是在这个语义向量上做最终的逻辑回归式判断。微调过程,就是让BERT编码器和分类头的参数一起调整,最终适配我们的标注数据。
如果遇到特殊需求,比如多标签分类或者输出一个连续数值,也可以自己替换分类头。核心操作就是去掉最后一层线性层,换成对应维度的新层。不过复现这篇论文的标准三分类,直接用官方封装就够。
4.3 训练参数配置:为什么是这个值
微调BERT的参数选择,是整个复现过程中最影响结果的部分。我刚开始复现的时候,直接沿用了一个普通图像分类项目里的学习率1e-3,结果训练集loss高居不下,跑了十分钟准确率还在50%边缘。后来才意识到问题出在参数选择上。
用到的核心参数配置如下:
| 参数 | 推荐值 | 理由 |
|---|---|---|
| 学习率 | 2e-5 ~ 5e-5 | 预训练模型参数已具备语言知识,微调步长必须小 |
| batch_size | 16或32 | 显存允许下尽量大,梯度估计更稳定 |
| epoch | 3 | 小数据集上3轮足够,再多容易过拟合 |
| optimizer | AdamW | 带权重衰减,适配Transformer训练 |
| scheduler | warmup + linear decay | 前10%步数线性预热,防止初始震荡 |
代码实现如下:
from transformers import AdamW, get_linear_schedule_with_warmup optimizer = AdamW(model.parameters(), lr=2e-5, weight_decay=0.01) total_steps = len(train_loader) * epochs scheduler = get_linear_schedule_with_warmup( optimizer, num_warmup_steps=int(0.1 * total_steps), num_training_steps=total_steps )为什么学习率要用2e-5这种极小值?这个原理可以这样理解:BERT的预训练参数已经学到了海量的语言知识,微调的目标是让模型在保留原有能力的基础上适应你的特定任务。如果学习率太大,相当于把原来的知识全盘打乱重学,学术界称为“灾难性遗忘”。2e-5意味着每次参数只做微小调整,像给一个经验丰富的分析师做业务培训,而不是把他送回大学重新读一遍。这个原则在几乎所有预训练模型微调场景中都成立。
4.4 完整训练循环与梯度裁剪
训练循环的完整写法如下:
from tqdm.auto import tqdm model.to(device) model.train() for epoch in range(epochs): total_loss = 0 for batch in tqdm(train_loader, desc=f"Epoch {epoch+1}"): batch = {k: v.to(device) for k, v in batch.items()} outputs = model(**batch) loss = outputs.loss loss.backward() torch.nn.utils.clip_grad_norm_(model.parameters(), max_norm=1.0) optimizer.step() scheduler.step() optimizer.zero_grad() total_loss += loss.item() print(f"Epoch {epoch+1} loss: {total_loss / len(train_loader):.4f}")梯度裁剪这一行容易被人忽略,我建议一定加上。max_norm=1.0的意思是当梯度的全局范数超过1时,等比缩放到1,防止个别异常样本把梯度推得过大而炸掉loss。尤其在标注数据存在噪声的情况下,梯度裁剪能显著提升训练稳定性。
训练过程中loss从2左右逐步降到0.2以下,是正常信号。如果loss在训练几轮后仍然高于1,说明参数配置或数据标签可能存在问题,需要停下来排查。
4.5 评估与结果解读
训练完成后,用验证集做评估:
from sklearn.metrics import accuracy_score, classification_report model.eval() preds, true_labels = [], [] with torch.no_grad(): for batch in val_loader: batch = {k: v.to(device) for k, v in batch.items()} logits = model(**batch).logits preds.extend(torch.argmax(logits, dim=-1).cpu().numpy()) true_labels.extend(batch["labels"].cpu().numpy()) print(accuracy_score(true_labels, preds)) print(classification_report(true_labels, preds, target_names=["负面", "中性", "正面"]))到这里,一条完整的论文方法复现链路就走通了。如果你之前熟悉的机器学习流程是“TF-IDF + 逻辑回归”,那么你只要把特征提取部分换成BERT,分类头换成线性层,整个思路是一脉相承的。你的模型从“人工设计的词频特征”跃升到了“预训练模型自动学习的语义特征”,复杂度和效果都上了一个台阶。
5. 常见问题与排查实录
5.1 显存不足的常规解法
训练时最常遇到的报错就是CUDA out of memory。解决方向从低到高排列:
- 把batch_size从32降到16、8,这是最直观的手段。
- 把max_len从256降到128,短文本场景下准确率几乎不掉。
- 在验证阶段前后调用
torch.cuda.empty_cache()释放缓存碎片。 - 使用混合精度训练,在transformers的Trainer里直接设
fp16=True即可。
我第一次复现时贪心,把max_len设成512、batch_size设成32,结果一块6GB显存的卡连第一个epoch都没跑完就爆了。后来把配置降到max_len=128、batch_size=16,训练速度和显存占用都回到了健康区间,最终效果几乎没有损失。原因很简单,财经短文本的关键信息往往集中在开头几十个字,模型在注意力机制下本来就会对关键部分赋予更高权重,截断本身不会带来太大信息损失。
5.2 训练loss不降或准确率卡在基线
遇到这种情况先不要怀疑模型结构,先检查数据和代码。我总结的排查顺序是:
- 看标签映射对不对。一个很隐蔽的低级错误是把标签字符串和索引映射反了,模型学到的其实是随机映射,自然不收敛。
- 看训练集和验证集有没有重叠文本。如果有重复,训练时的验证指标虚高,一到真实场景就露馅。
- 看学习率是否过大。如果用的AdamW但学习率设成0.01,预训练权重大概率会被冲散,loss会在高位震荡。
更高效的办法是做一个“小样本过拟合实验”:拿10条训练样本训练20步,看loss能不能降到接近0。能降到接近0,说明模型和代码链路没问题,问题出在数据层面;降不下去,说明链路有bug,需要逐层检查。
5.3 过拟合识别与正则化手段
小数据集上微调BERT,最典型的信号是训练集准确率逼近100%而验证集只有70%,两者差距悬殊。这是过拟合无疑。
BERT模型有上亿参数,如果训练数据只有几百条,模型确实有能力把训练样本“背下来”。这时候可以在几个方向做调整:
- 强化参数约束:
weight_decay从默认的0.01往上加。 - 开启Dropout机制:修改模型配置中的
hidden_dropout_prob和attention_probs_dropout_prob。 - 减少训练轮数到2个epoch,BERT微调本身就很容易收敛,不需要太多轮次。
数据层面也可以尝试半监督思路。先用微调好的模型给无标签文本打伪标签,把置信度高的样本加入训练集再训一轮。这不是论文复现的必要步骤,但如果你手头数据太少,这是一个实操中能明显提升效果的方向。
5.4 论文里的准确率90%多,自己只有70%
复现结果和论文对不上,是这个领域非常常见的现象,不一定是你的实现有问题。可能的原因有很多:
- 论文使用了专业团队多轮清洗和标注的数据,公开可得的原始文本质量达不到那个水平。
- 论文可能使用了模型集成,或者多次随机种子取平均值,单模型单次运行本身就有波动。
- 论文的任务定义和你理解的不一致,比如它做的是二分类,而你做的是三分类。
我的建议是:不要为了强行对齐论文指标而去反复调测试集的超参数,那是自欺欺人。复现的意义在于把方法跑通、把流程理解、把结果解释清楚,只要你的评估过程规范、结论合理,指标存在合理范围内的差异完全可以接受。
5.5 编码乱码问题
经管数据经常从PDF、Excel、数据库导出,编码混乱很常见。Pandas读取时先试encoding='utf-8',如果报错再试encoding='gbk'。实在不行才用errors='ignore'。但要注意,errors='ignore'会默默删除无法解码的字符,这些字符可能是文本语义的一部分,会影响模型判断。所以能正常解码就尽量正常解码,不要偷懒依赖这个参数。
6. 复现之外的扩展建议
6.1 更换更强模型只需要改一行
这套代码最大的价值在于,换模型只需要改model_name这一个变量。中文文本分类领域,比原生bert-base-chinese更强的替代方案有好几个,比如hfl/chinese-bert-wwm-ext和hfl/chinese-roberta-wwm-ext。这两个模型在中文语料上使用全词掩码策略预训练,对中文语义的建模比原版BERT更细腻。
我在新闻情感分类任务上测试过,在同等训练条件下,全词掩码版本比原版BERT的macro-F1高出1到2个百分点。升级方式就是把代码里那行model_name改掉,其他代码完全不用动。这种“升级只改一行”的体验,是HuggingFace这套设计带来的最大红利。
6.2 生成式大语言模型分类的替代路线
如果你要处理的文本是中英文混杂、任务定义比较抽象,或者你暂时没有标注数据,可以考虑用生成式大语言模型做少样本分类。做法是在系统提示词里写出任务描述和几个示例,让模型对每条文本输出对应类别。这种方式不需要训练,给几条样例就能跑。
但它也有明显短板:成本按token计费、输出结果不稳定、可复现性弱。同一个问题换一次提示词措辞,结果可能就不同。在严谨的论文研究里,我仍然推荐BERT微调方案,生成式路线更适合探索性的预实验和一次性小规模分析。
6.3 接回计量分析的完整闭环
跑出文本标签并不是终点。经管研究的后续动作,通常是把预测出的情感或类别标签作为解释变量或被解释变量,构建面板数据,再做回归分析或事件研究。为了实现这个闭环,模型预测结果最好输出成CSV或Excel,带上原始文本的编号、时间和预测标签。
我通常会这样保存结果:
import pandas as pd df_result = pd.DataFrame({ "text_id": test_ids, "text": test_texts, "true_label": true_labels, "pred_label": preds }) df_result.to_csv("prediction_results.csv", index=False, encoding="utf-8-sig")注意编码用了utf-8-sig,这样Excel直接双击打开也不会乱码。后续把这个文件合并回计量分析的数据表,就能做各种回归了。这一步虽然简单,但直接决定了大模型输出能否顺畅接入Stata、R或Python的计量流程。
最后说点实在的。复现一篇顶刊论文的方法,最有价值的部分不是那几行代码,而是理解作者为什么这么设计。用BERT微调做经管文本分类,本质上是在“把文本变成可计量的变量”这条路上走了一条性价比最高的捷径——不依赖外部API,训练成本可控,结果可复现,还能直接接回计量分析的流程里。
我个人在实际操作中的一个建议是:不要第一次跑就急着看指标,先跑通一条最小链路。用一个500条、1000条的样本集把数据和代码全流程跑通,再去追求完整数据集上的高准确率。这个过程你踩过的每一个坑,都会变成你做经管文本研究时不可替代的底气。
这套代码我已经完整跑通,后面遇到合适的场景,我准备再写一篇关于长文本分类时的双阶段处理策略,以及在分类结果基础上构建面板数据的实操分享。希望这篇复现笔记对准备入坑经管文本分析的你有所启发。